X

Anmelden

Zur Bestätigung jetzt anmelden

Passwort vergessen?

... oder mit Facebook anmelden:

Du hast noch keinen Zugang zu AndroidPIT? Registrieren

Spieleentwicklung 101

Datei:license.png

Spielentwicklung 101 von Mario Zechner steht unter einer Creative Commons Namensnennung-Nicht-kommerziell-Weitergabe unter gleichen Bedingungen 3.0 Unported Lizenz.

Inhaltsverzeichnis

[Verstecken]

Danksagung

Großer Dank gebührt Antonia Wagner. Sie hat in minutiöser Kleinstarbeit all die großen und kleinen orthographischen und grammatischen Drachen aus diesem Artikel entfernt. Danke Antonia!

Einleitung

Dieser Artikel soll in mehreren Etappen an das Thema Spieleentwicklung heranführen. Eine allgemeine Einführung in die Thematik bildet dabei den Grundstock. Diese soll Grundbegriffe und Mechanismen im Überblick erklären, anschließend werden die einzelnen Teilaufgaben bei der Entwicklung eines Spiels erklärt. Abschließend werden die so erarbeiteten Themen in eine kleine Space Invaders-Variante gegossen.

Als kleine Vorwarnung: Ich schreibe seit ca. zehn Jahren kleinere und größere Spiele. Meine Herangehensweise ist sicher nicht die Optimalste. Auch können mir im Rahmen dieses Artikels faktische Fehler unterlaufen, ich werde aber versuchen, diese zu vermeiden. Auch werde ich, wo angebracht, Anglizismen verwenden, da diese das Googlen nach weiterführendem Material erleichtern. Außerdem werde ich, für Begriffe, die man eventuell Nachschlagen möchte, einige Wikipedia-Links einbauen. Also: Los geht's.

Was muss ich vorab wissen?

In diesem Artikel setze ich ein paar Dinge als Minimum voraus. Keine Angst, höhere Mathematik gehört nicht dazu ;)

Den Code zu diesem Tutorial könnt ihr euch per SVN von der Adresse http://android-gamedev.googlecode.com/svn/trunk/ holen. Im Projekt gibt es eine Main-Activity die euch gleich wie in den SDK Demos eine Liste an Applikationen zeigt. Einfach das Projekt aus dem SVN herunterladen, in Eclipse importieren und eine Run Configuration anlegen und die Default Launch Activity starten.

Wo fang ich an?

Am Anfang jedes Spiels steht eine Idee. Wird es ein Puzzler? Ein Rundenstrategiespiel? Ein First-Person-Shooter? Auch wenn diese Genres unterschiedlicher nicht sein könnten, so unterscheiden sie sich im Grunde ihres Daseins oft wenig. Ein Spiel kann in mehrere Module zur Erledigung diverser Aufgaben eingeteilt werden:

  • Applikationsgerüst
  • Eingabe Modul
  • Datei I/O Modul
  • Grafik Modul
  • Sound Modul
  • Netzwerk Modul
  • Simulations-Modul

Im Folgenden wollen wir uns mit diesen sechs Modulen etwas genauer beschäftigen.

Das Applikationsgerüst

Das Applikationsgerüst stellt die Basis für das Spiel dar. In der Regel ähnelt dieses herkömmlichen Applikationsgerüsten nicht. Spiele sind in den meisten Fällen nicht Event-basiert, d.h. sie laufen ständig, zeichnen dabei die Spielwelt permanent neu, holen sich dauernd neue Benutzereingaben und simulieren die Welt (für Kartenspiele und Ähnliches muss dies natürlich nicht gelten). Wenn man nicht gerade für eine Konsole programmiert, stellt sich einem jedoch das Problem, dass die meisten Betriebssysteme eventbasierte Programmierung als Paradigma gewählt haben. So auch auf Android. Applikationen werden dabei nur bei Bedarf neu gezeichnet, zum Beispiel wenn der Anwender Text eingibt, einen Button drückt und so weiter. Es wird also nur dann Code ausgeführt, wenn es eine Benutzereingabe gibt. Im Allgemeinen hebelt man dies aus, indem man einen separaten Thread startet, der das Betriebssystem veranlasst, die Applikation permanent neu zu zeichnen. In diesem Thread befindet sich so gut wie immer eine Schleife, auch Main Loop genannt, innerhalb derer sich immer dasselbe abspielt. Das sieht stark vereinfacht so aus:

while( !done )
{
   processInput( )
   simulateWorld( )
   renderWorld( )   
}  

Wie dieser Main Loop genau aussieht, hängt von vielerlei Faktoren ab, zum Beispiel dem verwendeten Betriebssystem, dem Spiel selbst und so weiter.

Im Rahmen dieses Artikels werden wir sehen, wie man dieses Konzept äußerst einfach auf Android implementieren kann.

Das Eingabe Modul

Um dem Spieler die Möglichkeit zu geben, in das Spielsystem eingreifen zu können, müssen dessen Eingaben irgendwie gelesen werden. Diese Aufgabe übernimmt das Eingabe-Modul. Tastaturen, Mäuse, Touch-Screens, Joysticks, Gamepads und einige andere exotische Möglichkeiten stehen hier zur Verfügung. Wie man an die Eingabe kommt, ist dabei wieder betriebssystemabhängig.

Android verfügt über einige Eingabe Möglichkeiten, wir werden uns mit den beiden wichtigsten beschäftigen

  1. dem Touch-Screen
  2. dem Accelerometer

Das Datei I/O Modul

Alle Ressourcen eines Spieles müssen in irgendeiner Form der Applikation zugänglich gemacht werden. In speziellen Fällen kann das Spiel diese On-the-fly zur Laufzeit prozedural selbst erstellen, meistens liegen diese aber in Form von Dateien auf einem Datenträger vor. Auch hier gibt es verschiedene Möglichkeiten der Ablage: Dateien können schön geordnet in Ordnern abgelegt werden, wild in einem einzigen Ordner gespeichert sein oder gar in einer Zip-Datei fein säuberlich gepackt vorliegen. Das I/O-Modul für Dateien soll dies abstrahieren, damit im Programmcode der Zugriff auf die Ressourcen erleichtert wird.

Android bietet hier mit seinem Ressourcen- und Asset-System schon einen netten Ansatz, auf das wir später noch zu sprechen kommen werden.

Das Grafik Modul

In unseren modernen Zeiten ist für viele Spieleentwickler dieser Teil eines Spiels wohl der wichtigste (teils zu Lasten des Spielspaßes). Dieses Modul übernimmt die Darstellung sämtlicher grafischen Inhalte des Spieles, sei dies das User Interface, welches in der Regel zweidimensional ist, oder die Spielewelt selbst, meist in der dritten Dimension. Da Letzteres oft rechenintensiv ist, wird spezielle Hardware verwendet, um das Ganze zu beschleunigen. Die Kommunikation mit dieser Hardware, ihr klar zu machen, wie, wo, was gezeichnet werden soll, sowie die Verwaltung von grafischen Ressourcen, wie Bitmaps und Geometry (auch Meshes genannt) ist die Hauptaufgabe dieses Systems. Hierunter fallen auch Dinge, wie das Zeichnen von Partikel-Effekten oder der Einsatz von so genannten Shadern (auf Android noch nicht möglich). Ganz allgemein kann festgehalten werden, dass die meisten Objekte, die simuliert werden auch eine grafische Entsprechung haben. Meiner Erfahrung nach ist es äußerst hilfreich die simulierte Welt komplett unabhängig von der grafischen Darstellung zu machen. Das Grafikmodul holt sich lediglich Information von der Welt-Simulation, hat aber auf diese keinen Einfluss. Wem das etwas zu vage ist: keine Angst, der Zusammenhang sollte spätestens beim Entwickeln des Space Invader-Klons erkenntlich werden. Es sei jedoch gesagt, dass dieser Ansatz es erlaubt, das Grafik Modul beliebig auszutauschen, zum Beispiel statt einer 2D-Darstellung das ganze auf 3D zu portieren, ohne dass dabei der Simulationsteil geändert werden muss.

Aufmerksamen Lesern ist vielleicht der Zusammenhang zwischen dem Grafik-Modul und dem Main Loop bereits aufgefallen. Neuen Grafikkarten wird meist mit Benchmarks zu Leibe gerückt, die die so genannten Frames per Second (kurz FPS) oder Frame Rate messen. Diese geben an, wie oft der Main Loop in einer Sekunde durchlaufen wurde. Es wird also gezählt, wie oft der Main Loop (Input verarbeiten, Welt simulieren und das Ganze dann zeichnen) in einer Sekunde durchlaufen wird. Im Zuge unserer Unternehmung werden wir immer ein Auge auf die Frame Rate werfen, um etwaige Engpässe in unserem Spiel identifizieren zu können.

Später werden wir sehen, wie wir Android 2D- und 3D-Grafiken über OpenGL ES entlocken können.

Das Sound-Modul

Soundeffekte und Musik gehören zu jedem guten Spiel. Dementsprechend kümmert sich das Sound-Modul um das Abspielen solcher Ressourcen. Dabei gibt es zwischen Soundeffekten und Musik einen wichtigen Unterschied. Soundeffekte sind in der Regel sehr klein (im Kilobyte-Bereich) und werden direkt im Hauptspeicher gehalten, da sie oft verwendet werden. Beispielsweise das Feuergeräusch einer Kanone. Musik wiederum liegt oft in komprimierter Form vor (mp3, ogg) und braucht unkomprimiert (und damit abspielbar) sehr viel Speicherplatz. Sie wird daher meist gestreamed, das heißt bei Bedarf stückweise von der Festplatte oder einem anderen Medium (DVD, Internet) nachgeladen. Auf Implementationssbene macht dies oft einen Unterschied, da dieser Nachlademechanismus meist selbst realisiert werden muss.

In Zeiten von Surround-Sound-Heimsystemen legen Spieleentwickler auch Wert auf dreidimensionalen Klang. Meistens, so wie bei der Grafik, hardwarebeschleunigt. Android bietet diese Möglichkeit noch nicht, erlaubt aber relativ schmerzlos das Abspielen von Soundeffekten und das Streamen von Musik, wie wir später noch sehen werden.

Das Netzwerk-Modul

Seit World of Warcraft und Counter Strike ist klar: an Multiplayer-Möglichkeiten kommt kein modernes Spiel vorbei! Das Netzwerk-Modul hat dabei gleich mehrere Aufgaben zu stemmen. Auf der einen Seite handhabt es die Kommunikation mit etwaigen Servern, sendet Mitteilungen von Spielern herum, lädt von Spielern gebastelte Levels ins Netz und so weiter. Dies sind quasi administrative Aufgaben und haben nur indirekt mit dem Spielgeschehen selbst zu tun. Auf der anderen Seite gilt es Spieledaten, wie aktuelle Positionen, das Abfeuern von Kugeln, das entsenden von Truppen und vieles mehr, das den anderen Rechnern im Netz mitgeteilt werden muss, die an der Partie teilnehmen. Abhängig vom Genre des Spiels kommen hier verschiedene Methoden zum Einsatz, um das Spielgeschehen zu synchronisieren. Dieser Themenbereich ist so groß und komplex, dass ihm am besten ein eigener Artikel gewidmet werden sollte. Im Rahmen dieses Textes werde ich nicht weiter auf diese Komponente eingehen.

Das Simulations-Modul

Damit sich die Dinge im Spiel bewegen, muss man sie auch irgendwie antreiben. Das ist die Aufgabe des Simulationsmoduls. Es beinhaltet sämtliche Informationen zum Spielgeschehen selbst, wie die Position von Spielfiguren, deren aktuelle Aktion, wie viel Munition noch übrig ist und so weiter. Auf Basis der Benutzereingaben, sowie der Entscheidungen einer möglicherweise implementierten Künstlichen Intelligenz, wird das Verhalten der Spielobjekte simuliert. Die Simulation läuft dabei immer Schrittweise ab. In jedem Durchgang des Main Loops wird die Simulation um einen Schritt vorangetrieben. Dies passiert zumeist zeitbasiert, das heißt, man simuliert eine bestimmte Zeitspanne. Für ein weiches Ablaufen wird als Zeitspanne meist die Zeit, die seit dem Zeichnen des letzten Frames vergangen ist, herangezogen. Ein kleines Beispiel: gegeben eine Kanonenkugel, die mit 10m/s nach rechts fliegt, schreiten wir einen Schritt in der Simulation weiter. Die Zeitspanne seit dem letzten Frame beträgt 0.016s (16 Millisekunden, entspricht einer Frame Rate von 60fps). 10m/s * 0.016s = 0.16m, das heißt die Kanonenkugel ist nach Abschluss dieses Simulationsschrittes um 16 Zentimeter weiter links, im Vergleich zum letzten Frame. Diese Art des Simulationsschrittes nennt man Frame Independent Movement und sollte Bestandteil jedes Simulations-Moduls sein. Wie der Name schon sagt, ist es egal, wie viel FPS das Spiel schafft, die Kanonenkugel wird sich auf allen Systemen gleich verhalten (wenn auch die Zwischenschritte andere sein mögen). Es sei angemerkt, dass man bei Verwendung von Physik-Systemen meist fixe Zeitschritte verwendet, da die kleinen Schwankungen beim Messen der Zeitspanne zwischen dem aktuellen und dem letzten Frame viele Physik-Systeme instabil machen können. Wir werden in unserem Space Invaders-Klon keine grandiosen Physikspielereien implementieren, daher bleiben wir bei der herkömmlichen zeitbasierten Methode, die die Frame Zeitspanne heranzieht.

Abhängig vom Spieletyp gehört auch die künstliche Intelligenz zum Simulations-Modul. Diese kann sehr simpel Ausfallen, zum Beispiel das Verhalten der Goombas in Super Mario, die nur dumm nach rechts und links laufen. In Echtzeitstrategiespielen kann diese schon um einiges komplexer werden. Der Terminus künstliche Intelligenz ist hier streng gesehen auch nicht ganz korrekt, in Ermangelung eines besseren Begriffs bleiben wir aber einfach dabei.

Und auf Android?

Wir wollen nur für all die oben genannten Module eine Entsprechung auf Android entwickeln. Wir beginnen mit der Activity selbst und versuchen das Main Loop-Muster dort zu implementieren. Das Verwalten von Dateien über Ressourcen und Assets werden wir uns als nächstes ansehen. Anschließend werden wir uns näher mit OpenGL ES beschäftigen und Android dazu bringen, für uns interessante Dinge zu zeichnen. Die Ausgabe von Soundeffekten und Musik bildet den Abschluss dieses Kapitels, womit wir dann für unseren Space Invaders-Klon gerüstet sind.

Im Zuge dieses Kapitels werden wir wiederverwendbare Komponenten entwickeln, schließlich wäre es nicht sinnvoll, jedes Mal das Rad neu zu erfinden. Alle Codes könnt ihr unter [http://code.google.com/p/android-gamedev/ http://code.google.com/p/android-gamedev/] finden und per SVN auschecken. Das Projekt beinhaltet ein paar Beispielprogramme zu den einzelnen Abschnitten, sowie den Space Invaders-Klon selbst.

Android Activity

Das Grundgerüst unseres Spiels bildet eine simple Activity. Dabei ergibt sich ein klassisches Henne/Ei Problem: wir müssen hier schon mit OpenGL ES beginnen, ohne uns damit auszukennen. Aber keine Angst, das Ganze erweist sich als relativ einfach.

Ziel dieses Kapitels wird es sein, eine lauffähige OpenGL ES-Activity zu erstellen, die den grundlegenden Activity-Life-Cycle respektiert. Seit SDK 1.5 gibt es die so genannten GLSurfaceView. Sie ist ein GUI-Baustein und ähnelt einer List View, die man einfach in die Activity einhängt. Die Initialisierung von OpenGL mit halbwegs guten Parametern wird dabei für uns übernommen. Des Weiteren startet sie einen zweiten Thread neben dem GUI-Thread der Activity, der das Neuzeichnen des Geschehens permanent anstößt. Hier sieht man schon erste Parallelen zum Main Loop Konzept. Wir werden davon Gebrauch machen.

Damit wir eine Möglichkeit haben, das Neuzeichnen selbst zu übernehmen, bietet die GLSurfaceView ein Listener-Konzept (auch Observer Design Pattern genannt). Eine Applikation, die sich in den Rendering-Thread der GLSurfaceView einhängen möchte, registriert bei dieser eine Implementierung des Interface Renderer. Dieses Interface hat drei Methoden, die abhängig vom Status der GLSurfaceView aufgerufen werden.

public abstract void onDrawFrame(GL10 gl)
public abstract void onSurfaceCreated(GL10 gl, EGLConfig config)
public abstract void onSurfaceChanged(GL10 gl, int width, int height)

Die Methode onDrawFrame ist jene, die die GLSurfaceView jedes Mal beim Neuzeichnen aufruft. Den Parameter gl, den wir dabei erhalten, werden wir später genauer besprechen.

Die Methode onSurfaceCreated wird aufgerufen, sobald die GLSurfaceView fertig initialisiert ist. Hier kann man verschiedene Setup-Aufgaben erledigen, wie zum Beispiel das Laden von Ressourcen.

Die Methode onSurfaceChanged wird aufgerufen, wenn sich die Abmessungen der GLSurfaceView ändern. Dies passiert, wenn der Benutzer das Android-Gerät kippt und so in den Portrait- oder Landscape-Modus schaltet. Die Parameter width und height geben uns dabei die Breite und Höhe des Bereiches an, auf den wir zeichnen und zwar in Pixeln. Diese Information werden wir später noch benötigen.

Unsere erste Activity hat also ein paar Aufgaben:

  • Erstellen einer GLSurfaceView und Einhängen in die Activity
  • Setzen einer Renderer Implementierung für die GLSurfaceView
  • Implementierung der Rendererer-Implementierung

Für eine saubere Implementierung werden wir einfach die Klasse Activity ableiten und diese GameActivity nennen. Dieser verpassen wir ein Attribut vom Typ GLSurfaceView, den wir in der onCreate Methode der Klasse instanzieren und in die Activity einhängen. Weiters implementiert unsere abgeleitete Activity das Interface Renderer. In zwei weiteren Attribut-Variablen speichern wir die aktuelle Größe des zu bemalenden Bereichs, die wir beim Aufruf der Methode onSurfaceChanged in Erfahrung bringen. Diesen Bereich nennt man im Übrigen auch Viewport. Wir werden diese Terminologie fortan übernehmen. Außerdem fügen wir noch Getter-Methoden in die Klasse ein, damit wir später auf die Abmessungen zugreifen können.

Um den Activity Life Cycle auch sauber zu implementieren, müssen wir die Methoden onPause und onResume noch überschreiben. In diesen Rufen wir dieselben Methoden auch für unsere GLSurfaceView auf. Dies ist nötig, damit verschiedene Ressourcen sauber verwalten werden können.

In unserer Activity werden wir auch gleich die Frame Rate und die Zeitspanne zwischen dem aktuellen und dem letzten Frame messen. Die Zeitspanne nennt man auch Delta Time, wieder ein Begriff, den wir uns ab jetzt merken werden. Um eine genaue Zeitmessung im Millisekundenbereich zu gewährleisten, verwenden wir die System.nanoTime Methode. Diese liefert uns die aktuelle Zeit in Nanosekunden als long-Typ zurück. Für die Delta Time merken wir uns den Zeitpunkt des letzten Frames als eigenes Attribut in der Klasse. Die Delta Time selbst errechnen wir dann in der onDrawFrame-Methode, indem wir einfach die aktuelle Zeit abzüglich der zuvor gespeicherten Zeit nehmen. Diese Delta Time speichern wir in einem weiteren Attribut, um später im Spiel einfach darauf zugreifen zu können. Dies ist notwendig, da wir sie für das Frame Independent Movement benötigen. Zum Abschluss schreiben wir die aktuelle Zeit wieder in das Attribut , das für die Speicherung der Delta Time-Berechnung im nächsten Frame vorgesehen ist.

Als letzten Puzzle-Stein werden wir noch an unserem Design etwas feilen. Wir wollen unsere Activity ja nicht jedes Mal neu schreiben, darum führen wir ein eigenes Listener Konzept ein. Dies machen wir über ein kleines Interface, das zwei Methoden hat.

public interface GameListener
{
   public void setup( GameActivity activity, GL10 gl );
   public void mainLoopIteration( GameActivity activity, GL10 gl );
}

Der GameActivity spendieren wir eine Methode setGameListener, der wir eine GameListener-Implementierung übergeben können. Die Activity merkt sich diesen Listener und ruft seine Methoden entsprechend auf. Die Methode Setup wird dabei nach dem Start des Spiels aufgerufen und ermöglicht es uns, Ressourcen zu laden, die wir dann später im Main Loop brauchen. In der GameActivity rufen wir diese Methode in onSurfaceCreated auf, falls ein GameListener gesetzt wurde. Die Methode mainLoopIteration implementiert den Körper des Main Loop. Hier werden wir später dann alles für unser Spiel nötige erledigen, wie die Welt zu simulieren oder diese zu zeichnen. Diese Methode wird in der Activity in onDrawFrame aufgerufen. Fangen wir mit der Programmierung eines neuen Spiels an: Als erstes implementieren wir lediglich eine Activity. Diese leitet direkt von GameActivity ab und wir setzen ihr einen GameListener in der onCreate Methode. Der GameListener ist also das eigentlich Spiel.

Damit haben wir vorerst den Grundstock für unser erstes Spiel gelegt, eine voll Funktionsfähige Activity, die uns die Verwendung von OpenGL erlaubt. Wir werden die GameActivity-Klasse gleich noch ein wenig ausbauen, um dort auch Eingaben entgegennehmen zu können. Den Code für die Klasse könnt ihr euch unter [http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/tools/GameActivity.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/tools/GameActivity.java] ansehen.

Touch Screen & Accelerometer

Das Lesen von Benutzereingaben auf Android ist, wie vieles anderes, wieder über ein Listener-Konzept implementiert. Wir vernachlässigen hier Eingaben über den Trackball, die Tastatur oder das D-Pad, da dies den Rahmen dieses Artikels wohl sprengen würde. Wir konzentrieren uns zuerst auf Touch-Eingaben und gehen später zum Accelerometer über.

Für die Eingabe per Touch brauchen wir ein GUI-Element, das diese auch entgegennimmt. Mit der GLSurfaceView in unserer GameActivity haben wir bereits einen geeigneten Kandidaten. Es gilt somit nur einen entsprechenden Listener bei der GLSurfaceView zu registrieren. Das Interface, das wir implementieren wollen, nennt sich OnTouchListener und hat nur eine einzige Methode

public abstract boolean onTouch(View v, MotionEvent event)

Für uns interessant ist der Parameter event vom Typ MotionEvent. Dieser beinhaltet die Koordinaten des Touch-Events, sowie die Aktion. Also ob der Finger gerade aufgesetzt wurde, ob er gezogen wird oder ob er wieder vom Display genommen wurde. Die Koordinaten sind dabei zweidimensional und relativ zum View, für den wir den Listener registriert haben. Über die Methoden MotionEvent.getX() und MotionEvent.getY() erhalten wird die Werte. Abhängig davon, ob wir im Landscape- oder Portrait-Modus sind, sind die x- und y-Achse ausgerichtet. Die positive x-Achse zeigt dabei immer nach rechts, die positive y-Achse nach unten. Der Nullpunkt befindet sich also in der oberen linken Ecke. Dies ist ein wichtiges Faktum, das vielen Neulingen am Anfang Probleme macht, da es nicht mit dem in der Schule gelernten, klassischen kartesischen Koordinatensystem übereinstimmt. Die Koordinaten werden dabei wieder in Pixel angegeben.

Welche Aktion gerade aktuell ist, liefert uns die Methode MotionEvent.getAction(). Wir werden auf die Aktionen MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP und MotionEvent.ACTION_MOVE reagieren. Die GameActivity lassen wir das Interface OnMotionListener implementieren. Wir spendieren ihr auch drei neue Attribute touchX, touchY und isTouched, in denen wir den aktuellen Status des Touch-Screen speichern. Kommt ein MotionEvent.ACTION_DOWN-Event daher, speichern wir die x- und die y-Koordinate in touchX bzw. touchY und setzen isTouched auf true, bei einem MotionEvent.ACTION_MOVE machen wir dasselbe und im Falle von MotionEvent.ACTION_UP setzen wir isTouched auf false. Damit wir im GameListener auf den aktuellen Status zugreifen können, geben wir der GameActivity auch noch Getter-Methoden, um die Werte auslesen zu können. Das Auslesen des aktuellen Status nennt man allgemein auch Polling.

Es sei angemerkt, dass die onTouch Methode im GUI-Thread und nicht im Render-Thread der GLSurfaceView vom Betriebssystem aufgerufen wird. Normalerweise müsste man sich hier Sorgen um etwaige Thread-Synchronisierung machen. Da es sich bei den Attributen, die den Status halten aber um Plain Old Datatypes handelt und das Schreiben auf diese atomar ist, können wir das hier einfach übersehen.

Die GameActivity registriert sich selbst als OnTouchListener bei der GLSurfaceView in der onCreate-Methode, die wir dementsprechend erweitern.

Der Accelerometer ist ebenfalls wieder über ein Listener-Konzept ansprechbar (ja, das zieht sich so ziemlich durch alles durch). Das entsprechende Interface nennt sich SensorEventListener. Diesen registriert man aber nicht bei einem View, sondern beim SensorManager. Zugriff erhalten wir auf diesen wie folgt:

SensorManager manager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);

Bevor wir uns dort registrieren können, müssen wir aber zuerst einmal prüfen, ob der Accelerometer überhaupt verfügbar ist. Dies funktioniert so:

boolean accelerometerAvailable = manager.getSensorList(Sensor.TYPE_ACCELEROMETER).size() > 0;

Ist ein Accelerometer vorhanden, können wir uns ohne große Probleme bei diesem registrieren:

Sensor accelerometer = manager.getSensorList(Sensor.TYPE_ACCELEROMETER).get(0);
if(!manager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_GAME ) )
   accelerometerAvailable = false;

Vom Manager holen wir uns den ersten Accelerometer-Sensor, den wir finden (in der Regel gibt es davon nur einen). Danach registrieren wir uns über die SensorManager.registerListener()-Methode. Dieser Vorgang kann fehlschlagen, deshalb prüfen wir auch, ob es geklappt hat. Der Parameter SensorManager.SENSOR_DELAY_GAME gibt dabei an, wie oft das Betriebssystem den Accelerometer abtasten soll, in diesem Fall oft genug, um für ein Spiel zu genügen.

Was noch bleibt, ist das Verarbeiten der Sensor-Events. Das machen wir in der SensorEventListener.onSensorChanged()-Methode, die wir implementieren.

public abstract void onSensorChanged(SensorEvent event)

Ähnlich wie beim Verarbeiten von Touch bekommen wir hier wieder ein Event, in diesem Fall vom Typ SensorEvent. Diese Klasse besitzt ein öffentliches Attribut namens values, das die für uns relevanten Werte enthält. Von diesen gibt es drei Stück, gespeichert an den Indizes 0 bis 2. Diese drei Werte geben dabei die Beschleunigung in Meter pro Sekunde entlang der x-, y- und z-Achse des Android-Geräts an. Der maximal Wert beträgt dabei jeweils +-9.81m/s, was der Erdbeschleunigung entspricht. Hält man das Android-Gerät im Portrait-Mode, so gehen die positive x-Achse nach rechts, die positive y-Achse nach oben und die positive Z-Achse gerade aus durch das Gerät.

Dies bleibt auch so, wenn man das Gerät im Landscape-Modus hält. Wir werden dann bei der Space Invaders-Umsetzung sehen, wie wir diese Werte einsetzen können.

Gleich wie für Touch-Events spendieren wir der GameActivity einige neue Dinge. Zu Beginn wäre da ein neues Attribut vom Typ float-Array. Diese hält unsere drei Accelerometer-Werte. Außerdem lassen wir die Activity das SensorEventListener-Interface implementieren. Zum Abschluss brauchen wir noch drei Methoden, die uns jeweils den Accelerometer-Wert für eine Achse liefern und wir sind fertig. Gleich wie für Touch-Events können wir damit den Accelerometer-Status auslesen.

Eine Beispiel-Applikation, die den aktuellen Touch- und Accelerometer-Status per LogCat ausgibt, findet ihr unter [http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/InputSample.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/InputSample.java]. Diese zeigt auch gleich, wie wir ab jetzt neue Samples und das eigentlich Spiel aufbauen werden. Wir leiten zuerst von GameActivity ab, registrieren uns in der onCreate Methode als GameListener bei uns selbst und befüllen dann die setup- und render-Methode mit unserem Applikations-Code. Einfach und elegant.

Ressourcen, Assets und die SD-Karte

Datei-Ein- und Ausgabe auf Android ist ein weites Land. Mehrere Möglichkeiten stehen uns zur Verfügung, wir werden kurz auf alle eingehen, kleine Code-Stücke sollen illustrieren, wie man die einzelnen Möglichkeiten anwenden kann.

Ressource

Ressourcen stellen den von Google gewünschten Weg zur Verwaltung von Dateien dar. Sie werden im Android-Projekt in speziell dafür vorgesehene Ordner gespeichert und sind dann im Applikations-Code über Identifier direkt ansprechbar. Für die Spieleentwicklung sind sie meiner Ansicht nach nur bedingt von Nutzen, da die vorgegebene Verzeichnisstruktur etwas einschränkt. Auf Ressourcen gibt es nur Lesezugriff, da sie direkt in der APK-Datei der Applikation abgelegt werden. Ähnlich wie Ressourcen in normalen Java Jar-Dateien. Einen schönen Überblick bietet folgender Link, wir lassen Ressourcen einmal außen vor und wenden uns dem nächsten Kandidaten zu.

Assets

Assets werden ebenfalls wie Ressourcen direkt in der APK-Datei eingepackt. Der Vorteil liegt hier in der freien Wahl der Verzeichnisstruktur. Sie ähneln damit viel mehr dem herkömmlichen Java Ressourcen-Mechanismus (und sind mir daher auch sympathischer). Im Android-Projekt kann man unter dem Assets-Verzeichnis seine eigene Struktur beliebig anlegen. Der Zugriff auf ein Asset läuft dabei, wie gewohnt, über InputStreams:

InputStream in = activity.getAssets().open( "path/to/resource" );

Wie Ressourcen sind auch Assets nur lesbar.

SD Karte

Wenn der Besitzer des Android-Geräts eine SD-Karte eingelegt hat, kann man in der Regel auf dieser schreiben und lesen. Dazu bedarf es in der Datei AndroidManifest.xml des Projektes eines Zusatzes:

<uses-permission android:name="Android.permission.WRITE_EXTERNAL_STORAGE"/>

Ein- und Ausgabe funktioniert dann über die herkömmlichen Java Klassen.

FileInputStream in = new FileInputStream( "/sdcard/path/to/file" );
FileOutputStream out = new FileOutputStream( "/sdcard/path/to/file" );

OpenGL ES

Jetzt geht's ans Eingemachte. OpenGL ES ist eine Schnittstelle, die es uns erlaubt direkt mit der Grafikkarte eines mobilen Geräts zu sprechen. Der Standard wurde von mehreren Herstellern gemeinsam entworfen und lehnt sich stark an die Variante an, die in herkömmlichen PCs, aber auch in Workstations zum Einsatz kommt (genauer an die Version 1.3). Im Rahmen dieses Artikels werden wir uns die wichtigsten Dinge zu Gemüte führen, verständlicherweise kann ich hier aber nicht auf alles und jedes eingehen. Bevor wir uns in die Untiefen von OpenGL ES stürzen, müssen wir uns aber noch ein paar grundlegende Dinge anschauen, die allgemein in der Computergrafik gelten.

Grundlegendes zur Grafikprogrammierung

Die Entwicklung im Grafikbereich war in den letzten Jahrzehnten extrem. Viele Dinge haben sich geändert, bei der Programmierung blieb aber auch einiges gleich. Grundlage für so ziemlich jede Art von Grafikprogrammierung ist der so genannte Frame Buffer. Dieser ist ein Teil des Video-RAM und entspricht in Java Termen einem großen eindimensionalen Array, in dem die Farbwerte jedes Pixels für das aktuell am Bildschirm angezeigte Bild gespeichert werden. Wie die Farbwerte codiert werden, hängt vom Bildschirmmodus ab. Hier kommt der Begriff der Farbtiefe ins Spiel. Diese spezifiziert, wie viele Bits pro Pixel verwendet werden. Herkömmlicherweise sind das bei Desktop Systemen 24- bzw. 32-Bit. Auf mobilen Geräten sind 16-Bit Farbtiefen weit verbreitet. Die Farbe selbst wird als Rot-Grün-Blau-Triple bzw. Rot-Grün-Blau-Alpha-Quadruple in diesen 16-, 24- oder 32-Bit abgelegt. Je nach Farbtiefe kann für jede der Komponenten natürlich eine größere oder kleinere Reichweite entstehen. Wir müssen uns aber Spaghettimonster sei dank bei OpenGL ES nicht oft und vor allem nicht so intensiv, wie zu DOS-Zeiten mit der Thematik auseinandersetzen. Farben werden in OpenGL ES normalerweise normiert, d.h. im Bereich zwischen 0 und 1 für jede der Komponenten der Farbe (rot, grün, blau, alpha = Transparenz) angegeben.

Wollen wir also die Ausgabe am Bildschirm ändern, so müssen wir den Framebuffer, genauer die Pixel im Framebuffer, manipulieren. Früher geschah das wirklich quasi noch per Hand, heutzutage wird uns diese direkte Manipulation des Framebuffer von Bibliotheken, wie OpenGL abgenommen. Grob gesagt zeichnet OpenGL für uns gefärbte, texturierte Dreiecke in den Framebuffer und das ganze hardwarebeschleunigt. Wir haben Einfluss darauf, wo im Framebuffer diese Dreiecke wie gezeichnet werden, wie wir später noch sehen werden.

Pixel müssen natürlich adressiert werden können. Man verwendet dazu ein zweidimensionales Koordinaten-System. Koordinaten in diesem System werden dabei in eine lineare Adresse im Framebuffer umgerechnet. Und zwar mit der einfachen Formel:

Adresse = x + y * Breite

Wobei mit Breite die Breite des Bildschirms in Pixel gemeint ist. Hier erklärt sich auch, wieso die y-Achse in diesem Koordinaten-System nach unten zeigt (wie jenes, das wir für Touch-Events verwenden). Die Adresse 0 im Frame Buffer entspricht dem Pixel in der oberen linken Ecke des Bildschirms und hat die Koordinaten 0,0. Bei CRT-Monitoren fängt der Kathodenstrahl in dieser Ecke an, die Daten für die Intensität, die er haben soll bekommt er vereinfacht gesagt aus dem Frame Buffer, wobei natürlich am Anfang dieses Frame Buffers zu lesen begonnen wird (Position 0, Koordinate 0,0).

OpenGL arbeitet intern auch in diesem Koordinaten-System, nach außen aber mit einem anderen. Unkonfiguriert liegt der Ursprung in der Mitte des Bildschirms, die positive x-Achse geht nach rechts und die positive y-Achse geht nach oben, dabei bewegen sich die x und y Koordinaten im Bereich -1, 1, ähnlich zu den Bereichen bei Farben. Wollen wir Pixel-perfekt arbeiten, müssen wir das OpenGL erst beibringen. Wir werden später noch sehen, wie wir das bewerkstelligen.

Bewegung am Bildschirm entsteht ähnlich wie bei einem Zeichentrickfilm. Es werden verschiedene Animationsphasen, oder Frames nacheinander in den Framebuffer geschrieben. Ist die Frequenz mit der wir die Frames schreiben hoch genug, entsteht beim Betrachter die Illusion von Bewegung. 24 Bilder pro Sekunde werden in der Regel bei Filmen gezeigt.

Ein wenig Mathematik

Ja, ohne Mathematik kommen wir leider nicht aus. Konkret brauchen wir ein wenig lineare Algebra. Klingt grauslich, ist es aber eigentlich gar nicht. Den Stoff, den wir uns hier zu Gemüte führen, sollten viele schon einmal in der Schule gehört haben. Wir werden uns kurz mit Vektoren in der dritten Dimension beschäftigen.

Definieren wir zuerst das Koordinaten-System von OpenGL, in dem wir uns dann bewegen werden. Die positive x-Achse zeigt nach rechts, die positive y-Achse zeigt nach oben und die positive z-Achse zeigt aus der Ebene heraus. Siehe dazu die nächste Grafik:

Datei:coordinatesystem.jpg

Einen Punkt in diesem System gibt man über die Verschiebung auf den drei Achsen an, d.h. ein Punkt hat 3 Koordinaten, x, y und z. Ein Vektor gibt eine Richtung im Koordinaten-System an und ist nicht mit einem Punkt gleichzusetzen. Vektoren können im System beliebig verschoben werden. Trotzdem werden wir die Termini Vektor und Punkt ein wenig durchmischen,da man im Alltag in der Regel mit Vektoren arbeitet. Wir werden im Artikel Vektoren wie folgt anschreiben:

v = [vx, vy, vz]

Vektoren werden fett gedruckt, Skalare (also einfach Zahlen) werden wir normal drucken.

Mit Vektoren kann man auch wunderbar rechnen. Als erstes wollen wir die Länge eines Vektors bestimmen:

|v| = Math.sqrt( vx * vx + vy * vy + vz * vz );

Das sollte euch bekannt vorkommen: die Länge eines Vektors leitet sich von Pythagoras' Satz ab. Die Notation auf der rechten Seite bedeutet "Länge des Vektors v".

Vektoren kann man auch addieren und subtrahieren:

a + b = [ax + bx, ay + by, az + bz]
a - b = [ax - bx, ay - by, az - bz]

Bei der Multiplikation sieht das ganz ähnlich aus:

a * b = ax * bx + ay * by + az * bz

Das nennt man auch das Skalarprodukt zweier Vektoren. Mit einem kleinen Kniff kann man mit diesem Skalarprodukt den Winkel zwischen zwei Vektoren messen:

winkel = Math.acos( a * b / ( |a| * |b| ) );

Ich mische hier Java und mathematische Notation etwas,da mir die Formatierungsmöglichkeiten fehlen und es so etwas verständlicher wird. Der Winkel ist dabei immer <= 180 Grad. Math.acos() liefert diesen Winkel jedoch nicht in Grad sondern in Bogenmaß welches man recht einfach mit Math.toDegrees() in Grad umrechnen kann. Alle trigonometrischen Funktionen der Klasse Math arbeiten übrigens mit Bogenmaß, sowohl was Parameter als auch was Rückgabewerte betrifft. Das sorgt oft für schwer zu findende Bugs, also immer daran denken.

Zum Schluss wollen wir noch auf Einheitsvektoren eingehen. Dies sind Vektoren, die die Länge eins haben. Um einen beliebigen Vektor zu einem Einheitsvektor zu machen, müssen wir dessen Komponenten einfach durch seine Länge dividieren.

 a' = [ax / |a|, ay / |a|, az / |a|]

Der Apostroph nach dem a zeigt an dass es sich um einen Einheitsvektor handelt. Wir werden Einheitsvektoren später für ein paar Kleinigkeiten benötigen.

Und damit sind wir mit dem Mathematik-Kapitel auch schon fertig. Ich hoffe es war nicht gar zu schlimm. Bei Unsicherheiten empfehle ich euch im Netz ein wenig in Material zum Thema zu stöbern.

Das erste Dreieck

Wie Eingangs schon erwähnt, ist OpenGL im Grunde seines Herzens eine Dreieck-Zeichenmaschine. In diesem Abschnitt wollen wir uns daran machen, das erste Dreieck auf den Bildschirm zu zaubern. Dazu erstellen wir eine neue Activity, die von GameActivity ableitet und die sich selbst als GameListener in der onCreate()-Methode registriert.

Wie wir schon Eingangs erwähnt haben nennen sich die Dinge, die wir zeichnen Meshes. Ein solches Mesh wird durch so genannte Vertices definiert. Ein Vertex entspricht dabei einem Punkt des Meshes mit verschiedenen Attributen. Anfangs wollen wir uns nur um die wichtigste Komponente kümmern, der Position. Die Position eines Vertex wird, ihr habt es erraten, als dreidimensionaler Vektor angegeben. Ein Punkt alleine macht noch kein Mesh, darum brauchen wir mindestens drei. Mehrere Dreiecke sind natürlich auch kein Problem, wir wollen aber klein anfangen.

OpenGL ES erwartet in seiner Basisversion 1.1 die Vertex Positionen in einem direct ByteBuffer. Definieren wir zur Übung einmal ein Dreieck in der x-y-Ebene mit Hilfe so eines ByteBuffers:

ByteBuffer buffer = ByteBuffer.allocateDirect( 3 * 3 * 4 );
buffer.order(ByteOrder.nativeOrder());
FloatBuffer vertices = buffer.asFloatBuffer();
vertices.put( -0.5f );
vertices.put( -0.5f );
vertices.put( 0 );		
vertices.put( 0.5f );
vertices.put( -0.5f );
vertices.put( 0 );	
vertices.put( 0 );
vertices.put( 0.5f );
vertices.put( 0 );

Ganz schön viel Code für so ein kleines Dreieck. Als erstes erstellen wir einen direct ByteBuffer der 3 * 3 * 4 Bytes groß ist. Die Zahl ergibt sich da wir 3 Vertices haben zu je 3 Komponenten (x, y, z) die jeweils 4 Byte Speicher brauchen (float -> 32-bit). Anschließend sagen wir dem ByteBuffer, dass er alles in nativer Ordnung speichern soll, d.h. in Big- oder Little-Endian. Den fertig initialisierten ByteBuffer wandeln wir dann in einen FloatBuffer um den wir mit der Methode FloatBuffer.put() befüllen können. Jeweils 3 Aufrufe definieren die Koordinaten eines Vertex' unseres Dreiecks. Der erste Vertex liegt links unter dem Ursprung, der zweite Vertex rechts unter dem Ursprung und der dritte Vertex direkt über dem Ursprung. Dazu folgendes Bild:

Datei:dreieck.jpg

Damit haben wir OpenGL aber noch immer nicht wirklich etwas verraten. Das machen wir doch gleich und veranlassen das Zeichnen unseres Dreiecks:

gl.glViewport(0, 0, activity.getViewportWidth(), activity.getViewportHeight());
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY );    
gl.glVertexPointer(3, GL10.GL_FLOAT, 0, vertices);
gl.glDrawArrays(GL10.GL_TRIANGLES, 0, 3);

Im ersten Methodenaufruf teilen wir OpenGL mit, welcher Bereich des Bildschirms bezeichnet werden soll. Die ersten beiden Parameter geben dabei die Startkoordinaten des zu bezeichnenden Bereichs an, die nächsten beiden seine Größe. Beide Angaben werden in Pixeln gemacht, hier sagen wir konkret, dass der ganze Bildschirm ausgenutzt werden soll.

Achtung: der Emulator benötigt unbedingt den Aufruf von glViewport. Auf Geräten ist der Viewport bereits auf den gesamten Bildschirm gesetzt, im Emulator hat der Viewport am Anfang die Größe 0,0. Nicht vergessen, da sonst nichts am Bildschirm gezeichnet wird und man stundenlang sucht (ja, ist mir auch schon passiert...)!

Im nächsten Aufruf sagen wir OpenGL, dass wir ihm jetzt gleich Vertex Positionen übergeben werden und er fortan beim Zeichnen immer den übergebenen FloatBuffer verwenden soll. Im dritten Aufruf teilen wir OpenGL mit, wo er die Positionsdaten findet. Der erste Parameter gibt dabei die Anzahl der Vertices an, der zweite gibt den Typen der Komponenten jeder Position an, in diesem Fall floats. Der dritte Parameter nennt sich stride und ist für uns ohne Belang, wir setzen ihn einfach auf 0. Der letzte Parameter ist der FloatBuffer, den wir zuvor mit den Positionen der 3 Vertices befüllt haben. Als Letztes befehlen wir OpenGL die eben definierten Vertices zu zeichnen. Der erste Parameter gibt dabei an, was wir zeichnen wollen. In diesem Fall: Dreiecke. Der zweite Parameter gibt an, ab welcher Position im FloatBuffer OpenGL beginnen soll, die Positionsdaten zu holen. Der letzte Parameter sagt OpenGL noch, wie viele Vertices wir gezeichnet haben wollen. Zeichnen wir Dreiecke, muss dieser Parameter immer ein Vielfaches von 3 sein. Und schon haben wir das erste Dreieck mit OpenGL gezeichnet! Zur Entspannung hier der gesamte Code dieses Beispiels:

public class TriangleSample extends GameActivity implements GameListener 
{
   private FloatBuffer vertices;	
   public void onCreate( Bundle savedInstance )
   {
      super.onCreate( savedInstance );
      setGameListener( this );
   }	
   @Override
   public void setup(GameActivity activity, GL10 gl) 
   {
      ByteBuffer buffer = ByteBuffer.allocateDirect( 3 * 4 * 3 );
      buffer.order(ByteOrder.nativeOrder());
      vertices = buffer.asFloatBuffer();
      vertices.put( -0.5f );
      vertices.put( -0.5f );
      vertices.put( 0 );
      vertices.put( 0.5f );
      vertices.put( -0.5f );
      vertices.put( 0 );	
      vertices.put( 0 );
      vertices.put( 0.5f );
      vertices.put( 0 );	
      vertices.rewind();
   }	
   @Override
   public void mainLoopIteration(GameActivity activity, GL10 gl) 
   {	
      gl.glViewport(0, 0, activity.getViewportWidth(), activity.getViewportHeight());
      gl.glEnableClientState(GL10.GL_VERTEX_ARRAY );    
      gl.glVertexPointer(3, GL10.GL_FLOAT, 0, vertices);
      gl.glDrawArrays(GL10.GL_TRIANGLES, 0, 3);
   }	
}

Alternativ kann man sich den Code etwas schöner formatiert unter [http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/TriangleSample.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/TriangleSample.java] ansehen.

Und hier ein Screenshot unseres Dreiecks

Datei:triangle.png

Farbspiele

Ein weißes Dreieck ist natürlich etwas langweilig. Um das zu ändern, verwenden wir, bevor wir glDrawArrays aufrufen, den Befehl

glColor4f( float r, float g, float b, float a );

R, g, b stehen für die drei Farbkomponenten, a steht für die Transparenz. Alle Werte sind im Bereich 0 bis 1 anzugeben. Setzen wir r und b auf 1 bekommen wir ein schönes pink:

Datei:pink.png

Ich habe vorher schon erwähnt, dass ein Vertex nicht nur eine Position hat. Solange wir diese nicht explizit definieren, hat jeder Vertex in einem Mesh die Farbe, die wir mit glColor4f angeben. Um jedem Vertex eine eigene Farbe zu geben, verwenden wir denselben Mechanismus, wie für die Vertex Positionen. Zuerst bauen wir wieder einen direct FloatBuffer, in den wir für jeden Vertex dir r, g, b und a Werte speichern:

buffer = ByteBuffer.allocateDirect( 3 * 4 * 4 );
buffer.order(ByteOrder.nativeOrder());
colors = buffer.asFloatBuffer();	
colors.put( 1 );
colors.put( 0 );
colors.put( 0 );
colors.put( 1 );
colors.put( 0 );
colors.put( 1 );
colors.put( 0 );
colors.put( 1 );	
colors.put( 0 );
colors.put( 0 );
colors.put( 1 );
colors.put( 1 );	
colors.rewind();

Da wir 3 Vertices haben, brauchen wir einen ByteBuffer, der 3 Farben hält, zu je 4 Komponenten (r, g, b, a) mit je 4 byte (floats). Den ByteBuffer schalten wir wieder auf native order und wandeln ihn in einen FloatBuffer um. Nun können wir ihn mit den drei Farben für unsere drei Vertices befüllen, hier rot (1, 0, 0, 1), grün (0, 1, 0, 1) und blau (0, 0, 1, 1). Beim Zeichnen sagen wir OpenGL, dass es unseren FloatBuffer für die Farben der Vertices verwenden soll:

gl.glEnableClientState(GL10.GL_COLOR_ARRAY );
gl.glColorPointer( 4, GL10.GL_FLOAT, 0, colors );

Zuerst sagen wir OpenGL, dass wir für die Farben der einzelnen Vertices einen FloatBuffer haben (glEnableClientState). Dann geben wir, gleich, wie bei den Vertex Positionen, an, wo dieser FloatBuffer zu finden ist (glColorPointer). Der erste Parameter sagt, wie viele Komponenten eine Farbe hat (4 -> r, g, b, a), der zweite Parameter gibt an, welchen Typ die Komponenten haben, der dritte Parameter ist wieder der stride und der vierte ist unser zuvor befüllter FloatBuffer. Und das war es auch schon wieder. Achtung: hat man den client state GL10.GL_COLOR_ARRAY aktiviert, wird jeder Aufruf von glColor4f ignoriert. Es muss dann unbedingt ein FloatBuffer mit glColorPointer angegeben werden, der zumindest so viele Farben besitzt, wie das Mesh Vertices hat, bzw. so viele, wie man Vertices bei glDrawArrays angibt!

Der gesamte Code zum Zeichnen unseres nun schön eingefärbten Dreiecks sieht so aus:

gl.glEnableClientState(GL10.GL_VERTEX_ARRAY );    
gl.glVertexPointer(3, GL10.GL_FLOAT, 0, vertices);
gl.glEnableClientState(GL10.GL_COLOR_ARRAY );
gl.glColorPointer( 4, GL10.GL_FLOAT, 0, colors );
gl.glDrawArrays(GL10.GL_TRIANGLES, 0, 3);

Hier zeichnet sich langsam ein Muster ab: für jede Komponente eines Vertex, also z.B. die Position oder die Farbe, geben wir einen Array (GL10.GL_VERTEX_ARRAY, GL10.GL_COLOR_ARRAY) mit glEnableClientState frei und geben dann mit einer der glXXXPointer Methoden an wo, das entsprechende "Array" (in unserem Fall in Form eines FloatBuffer) zu finden ist. Diese Methode des Zeichnens nennt man in OpenGL Vertex Arrays und ist die einzige Methode, mit der man in OpenGL ES 1.0 überhaupt etwas zeichnen kann. Wir werden vielleicht in einem anderen Artikel die so genannten Vertex Buffer Objects näher betrachten, die ab OpenGL ES 1.1 zur Verfügung stehen. Einstweilen bleiben wir aber bei den Vertex Arrays, da sie auf allen Android-Geräten funktionieren.

Hier noch ein Screenshot unseres farbigen Dreiecks:

Datei:color.png

Den Code für dieses Beispiel könnt ihr euch unter [http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/ColorSample2.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/ColorSample2.java] ansehen.

Texturen

So richtig peppig wird es, wenn man seinen Dreiecken Texturen verpasst. Dabei tapeziert man auf die Dreiecke eine Bitmap, die man zuvor geladen hat. Bevor wir uns an die Texturierung selbst machen, schauen wir uns schnell an, wie man eine Bitmap überhaupt lädt. Im Beispiel-Projekt habe ich im assets-Verzeichnis eine PNG-Datei namens "droid.png" abgelegt. Dieses laden wir in unserer setup Methode wie folgt:

try
{
   Bitmap bitmap = null;
   bitmap = BitmapFactory.decodeStream( getAssets().open( "droid.png" ) );
}
catch( Exception ex )
{
   // Oh no!
}

Sehr einfach: wir rufen die statische Methode decodeStream der Klasse BitmapFactory auf und übergeben ihr einen InputStream auf unser Bitmap Asset namens "droid.png". Da die Methode eine IOException wirft, machen wir noch einen try-catch-Block darum. Da wir klug genug waren, das Asset auch wirklich in das entsprechende Verzeichnis zu packen, sollte es aber keine Exception geben. Normalerweise behandele ich Exceptions beim Laden von Ressourcen mit einem Log Output und einem System.exit(-1). Wie ihr das löst, bleibt aber euch überlassen.

Das Texturieren selbst ist wieder relativ einfach. Die Bitmap, die man lädt wird in ein normiertes Koordinaten-System gelegt:

Datei:Texturecoordinates.png

Den Achsen geben wir zur Vermeidung von Verwechslungen mit dem Vertex-Positionen Koordinaten-System neue Namen, s nach Rechts und t nach unten. Egal, welche Abmessungen das Bild hat, Pixel werden immer im Bereich [0,0]-[1,1] angesprochen. Man kann so z.B. leicht eine hochauflösende Textur mit einer niedrig auflösenden Textur austauschen, ohne die Textur-Koordinaten des Meshes zu ändern. Was die Bildabmessungen betrifft, so gibt es eine Limitation auf Android. Es müssen Zweier-Potenzen sein, also 1, 2, 4, 8, 32, 64, 128, 256 usw. Maximal sollte man nicht mehr als 512x512 Pixel verwenden, die Hardware könnte das nicht mehr unterstützen. Die Bilder müssen dabei nicht quadratisch sein, sondern können z.B. auch die Abmessungen 32x64 oder 128x32 haben.

Damit unser Dreieck texturiert wird, müssen wir für jeden Vertex zuerst eine Textur-Koordinate angeben. Die Angabe erfolgt dabei im Koordinaten-System der Textur, also zweidimensional und jeweils zwischen 0 und 1 (man kann auch kleinere und größere Werte angeben, das schauen wir uns aber später an):

Datei:Texturecoordinates2.png

Hier haben wir unser Dreieck gemapped. Aufmerksame Leser wissen schon, was jetzt kommt: Die Koordinaten speichern wir wieder in einen FloatBuffer:

buffer = ByteBuffer.allocateDirect( 3 * 2 * 4 );
buffer.order(ByteOrder.nativeOrder());
texCoords = buffer.asFloatBuffer();	
texCoords.put(0);
texCoords.put(1);		
texCoords.put(1);
texCoords.put(1);		
texCoords.put(0.5f);
texCoords.put(0);
texCoords.rewind();

Die Größe ergibt sich wieder aus den drei Vertices, für die wir jeweils Textur-Koordinaten mit zwei Komponenten haben, die wiederum jeweils 4 Byte groß sind (float). Der Rest sollte selbst erklärend sein.

Bevor wir die Textur-Koordinaten als Vertex-Komponente OpenGL mitteilen, müssen wir uns noch um eine Kleinigkeit kümmern. Und zwar um das eigentliche laden der Textur. Wir haben zwar schon die Bitmap aus dem Asset geladen, eine Textur haben wir aber noch nicht erstellt. Das machen wir jetzt:

int[] TextureIDs = new int[1];
gl.glGenTextures(1, TextureIDs, 0);		
TextureID = TextureIDs[0];

Mit glGenTexturs weisen wir OpenGL an, uns eine neue Textur zu erstellen. Der erste Parameter gibt dabei an, wie viele Texturen wir erstellen wollen (eine), in den zweiten Parameter speichert OpenGL dann die ID(s) der neuen Textur(en). Der letzte Parameter ist nur ein Offset, ab dem OpenGL in dem übergebenen Array schreiben soll. Die erhaltene Textur-ID müssen wir uns merken, mit dieser aktivieren wir dann später die Textur.

Als nächstes müssen wir die Bitmap in die Textur laden. Hier hat uns das Android-Team einen großen Brocken Arbeit abgenommen und stellt uns die Klasse GLUtils zur Verfügung:

gl.glBindTexture( GL10.GL_TEXTURE_2D, TexturID );
GLUtils.texImage2D( GL10.GL_TEXTURE_2D, 0, bitmap, 0);

Zuerst müssen wir die Textur "binden", damit sie zur aktuell aktiven Textur wird. Dazu übergeben wir als ersten Parameter GL10.TEXTURE_2D (dessen Erklärung ich mir spare, das ist einfach immer so :)) und als zweiten Parameter die zuvor generierte Textur-ID. Erst dann können wir Daten in die Textur laden, ihre Konfiguration ändern oder sie als Textur für eines unserer Meshes verwenden. Als nächster Rufen wir die statische Methode texImage2D der Klasse GLUtils auf, die unsere zuvor geladene Bitmap in die Textur lädt. Den ersten Parameter ignorieren wir wieder, den zweiten auch (gibt den MipMap-Level an), als dritten Parameter übergeben wir die Bitmap und den letzten Parameter ignorieren wir auch wieder. Damit hat unsere Textur jetzt die Bilddaten, die sie haben soll.

Als letzten Schritt müssen wir die Textur jetzt noch konfigurieren:

gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_LINEAR );
gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_LINEAR );
gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_S, GL10.GL_CLAMP_TO_EDGE );
gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_T, GL10.GL_CLAMP_TO_EDGE );	

Die ersten beiden Methoden setzen die Filter der aktuell gebundenen Textur, die zum Einsatz kommt, wenn die Textur am Bildschirm größer als im Original ist, bzw. kleiner als im Original ist. Der letzte Parameter gibt dabei den Filter an. Hier hat man die Wahl zwischen GL10.GL_NEAREST, GL10.GL_LINEAR, GL10.GL_LINEAR_MIPMAP_NEAREST und GL10.GL_LINEAR_MIPMAP_LINEAR. GL10.GL_NEAREST ist der hässlichste, GL10.GL_LINEAR ist ein bi-linearer Filter, der ganz gute Ergebnisse bringt und die beiden Mip-Map-Filter ignorieren wir einstweilen wieder.

Die beiden anderen Methoden geben an, was geschehen soll, wenn der User Textur-Koordinaten angibt, die kleiner als 0 oder größer als 1 sind. Wir wählen hier GL10.GL_CLAMP_TO_EDGE was zur Folge hat, dass solche Texturen einfach auf den Bereich geschnitten werden ( kleiner 0 wird 0, größer 1 wird 1 ). Alternativ kann man hier GL10.GL_WRAP angeben. Dies hat zur Folge, dass die Koordinaten modulo 1 genommen werden. Eine 4.5 wird so zur 0.5 und so weiter. Damit kann man die Textur über ein Dreieck mehrere Male wiederholen. Die Angabe des Wrap-Modus erfolgt dabei für die s- und t-Komponente einzeln.

Damit haben wir die Textur fertig geladen und konfiguriert. Uns bleibt noch das überzeichnen mit der Textur. Hier der gesamte Code im Überblick:

gl.glEnable( GL10.GL_TEXTURE_2D );
gl.glBindTexture( GL10.GL_TEXTURE_2D, TextureID );		
gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY );
gl.glTexCoordPointer(2, GL10.GL_FLOAT, 0, texCoords );
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY );    
gl.glVertexPointer(3, GL10.GL_FLOAT, 0, vertices);		
gl.glDrawArrays(GL10.GL_TRIANGLES, 0, 3);

Zuerst müssen wir OpenGL sagen, dass es ab jetzt alle Meshes mit der aktuell gebundenen Textur texturieren soll, als nächstes binden wir unsere Textur. Dann geben wir an, dass unsere Vertices Textur-Koordinaten haben und wir diese übergeben werden, was im nächsten Aufruf mit glTexCoordPointer geschieht. Hier unser texturiertes Dreieck:

Datei:Texturetri.png

Den Code zum Beispiel findet ihr unter [[http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/TextureSample.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/TextureSample.java]

Es sei noch angemerkt dass man die Textur nur einmal erstellt (z.B. in der setup-Methode). Ich hatte da schon Code von einigen Leuten gesehen, die die Selbe Textur immer und immer wieder laden. Was für Meshes gilt: einmal machen, solang verwenden, wie nötig, dann die Ressourcen wieder freigeben. Im Fall von Vertex Arrays gibt es nichts zu tun. Im Fall von Texturen müssen wir diese löschen, was sehr einfach geht:

int[] TextureIDs = { TextureID };
gl.glDeleteTextures( 1, TextureIDs, 0 );

Wir geben einfach die Textur-ID an und schon ist die Textur Geschichte. Man sollte eine gelöschte Textur natürlich nach dem Löschen nicht mehr binden.

Und das war's wieder. Eigentlich keine Zauberei, ein wenig Code ist es aber schon. Wir werden darum zwei Klassen bauen, die uns für Meshes und Texturen ein wenig Arbeit abnehmen und den Code schlanker machen.

Mesh & Textur Klasse

Für euer Seelenheil hab ich zwei Klassen entwickelt, die ihr sehr einfach verwenden könnt. Zum einen haben wir da die Mesh Klasse:

public final class Mesh
{
   public enum PrimitiveType
   {
      Points,
      Lines,
      Triangles,
      LineStrip,
      TriangleStrip,
      TriangleFan
   }	
   public Mesh( GL10 gl, int numVertices, boolean hasColors, boolean hasTextureCoordinates, boolean hasNormals )	
   public void render( PrimitiveType type )	
   public void vertex( float x, float y, float z )
   public void color( float r, float g, float b, float a )	
   public void normal( float x, float y, float z )	
   public void texCoord( float s, float t )
   public void dispose( );

}

Ihr könnt sie über den Konstruktor einfach instanzieren. Den ersten Parameter erhaltet ihr in der GameListener.setup()- bzw. GameListener.render()-Methode. Der zweite Parameter gibt an, wie viele Vertices das Mesh insgesamt haben soll. Der dritte Parameter besagt, ob das Mesh auch Farben definiert, der vierte, ob Textur-Koordinaten dabei sein sollen und der letzte, ob Normalen vorhanden sind. Moment, Normalen? Die erklären wir an dieser Stelle nicht. Sie werden für die Beleuchtung von Meshes durch Lichtquellen benötigt. Damit wir in späteren Teilen einmal darauf eingehen können, habe ich sie gleich mit in den Quellcode eingebaut.

Nachdem ihr das Mesh instanziert habt, könnt ihr es sehr einfach befüllen. Unser Color Sample von oben würde z.B. so ausschauen:

mesh = new Mesh( gl, 3, true, false, false );
mesh.color( 1, 0, 0, 1 );
mesh.vertex( -0.5f, -0.5f, 0 );
mesh.color( 0, 1, 0, 1 );
mesh.vertex( 0.5f, -0.5f, 0 );
mesh.color( 0, 0, 1, 1 );
mesh.vertex( 0, 0.5f, 0);

Als Richtlinie gilt hier: zuerst immer alle Komponenten ungleich der Position für einen Vertex angeben (Color, Textur-Koordinaten, Normale) und zum fixieren des Vertex vertex mit der Position des Vertex aufrufen. Natürlich solltet ihr nicht mehr Vertices definieren, als ihr im Konstruktor angegeben habt. Zum Rendern des Mesh reicht folgender Aufruf

mesh.render(PrimitiveType.Triangles);

PrimitiveType ist, wie oben zu sehen, ein Enum, welches mehrere Arten von Primitiven definiert. Wir haben bis jetzt nur die Dreiecke besprochen, es sind aber auch andere Primitive möglich. Ihr könnt diese im Netz nachschlagen (z.B. Triangle Strip), um einen Einblick zu erlangen.

Ein schönes Feature der Klasse ist, dass ihr das Mesh, nachdem ihr es einmal gerendert habt, wieder neu definieren könnt. Ihr verwendet dazu einfach wieder die Methoden color, texCoord usw., wie vorher gezeigt. Wichtig dabei ist aber, dass das Mesh mindestens einmal zuvor gerendert wurde, da ansonsten ein interner Zeiger nicht zurückgesetzt wird. Ihr könnt euch den Code zur Mesh-Klasse unter [http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/tools/Mesh.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/tools/Mesh.java] ansehen. Wirklich etwas Neues mache ich dort nicht. Die Grundlagen dafür habt ihr bereits oben gesehen. Den Quellcode zu einem Beispiel-Programm, das die Mesh Klasse verwendet, findet ihr unter [http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/MeshSample.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/MeshSample.java]. Durch die Verwendung der Klasse wird der Code um einiges schlanker und verständlicher.

Als kleinen Bonus hab ich die Klasse auch noch etwas aufgedröselt und Vertex Buffer Objects implementiert. Diese werden verwendet, wenn das Gerät diese unterstützt. Sie geben ein wenig mehr Performance. Bei Geräten mit Android Version <= 1.5 sind sie auch die einzige Lösung das permanente Garbage Collecten, welches mit Vertex Arrays auftritt zu beenden. Leider ist da den Entwicklern von Android ein kleiner Bug unterlaufen, der bei direct-Buffern auftritt. Das ganze erfolgt transparent, ihr müsst euch also nicht darum kümmern. Seid ihr mit der Verwendung eines Mesh fertig, müsst ihr dieses per Aufruf der Methode Mesh.dispose() freigeben.

Die Textur Klasse ist noch einfacher und im Folgenden zu sehen.

public class Texture 
{	
   public enum TextureFilter
   {
      Nearest,
      Linear,
      MipMap
   }	
   public enum TextureWrap
   {
      ClampToEdge,
      Wrap
   }	
   public Texture( GL10 gl, Bitmap image, TextureFilter minFilter, TextureFilter maxFilter, TextureWrap sWrap, TextureWrap tWrap )	
   public void bind(  )	
   public void dispose( )	
   public void draw( Bitmap bmp, int x, int y )	
   public int getHeight() 
   public int getWidth() 

}

Beim Instanzieren geben wir wieder die GL10 Instanz an, ebenso wie beim Mesh. Außerdem übergeben wir die Bitmap, die gewünschten Vergrößerungs- und Verkleinerungs-Filter sowie die Wrap Modi für die s- und t-Textur-Koordinaten. Diese haben wir ja oben ganz kurz angerissen. Auch Mip-Mapping ist hier schon implementiert, einfach den minFilter auf TextureFilter.MipMap setzen. Des Weiteren gibt es eine Methode bind() die die Textur bindet, wie bei glBindTexture. Die Methode dispose löscht die Textur und gibt alle Ressourcen frei. Die Methode draw() ist ein sehr nettes Feature. Sie erlaubt es im Nachhinein eine andere Bitmap an eine bestimmte x-y-Position in der Textur zu zeichnen. Die Koordinaten werden dabei in Pixeln angegeben, der Ursprung ist das obere linke Eck, die positive y-Achse geht nach unten. Intern bindet die Methode die Textur vor dem zeichnen, man muss hier also auf den Seiteneffekt achten.

Was die Klasse nicht macht, ist das Einschalten von Texturierung über glEnable. Das also nicht vergessen. Ein Beispiel für die Verwendung der Textur-Klasse in Zusammenhang mit der Mesh-Klasse findet ihr unter [http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/TextureMeshSample.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/TextureMeshSample.java]. Das Mesh dort verwendet Farb- und Textur-Koordinaten, was einen hübschen Effekt hat :)

Damit haben wir jetzt zwei sehr kleine und feine Klassen, die uns viel Arbeit und Code abnehmen.

Projektionen

Ich bin ein wenig fies. Nach dem Kapitel über Vektoren hab ich versprochen, dass das alles an Mathematik war, was wir hier sehen werden. Ich hab gelogen. Wir werden uns jetzt mit Projektionen beschäftigen. Dabei gibt es zwei für uns relevante Arten:

Die Parallelprojektion wird auch orthographische Projektion genannt, die Zentralprojektion kennt man auch als perspektivische Projektion. Was genau macht eine Projektion? Sie nimmt unsere dreidimensionalen Vertex-Positionen und transformiert diese in 2D-Koordinaten am Bildschirm (vereinfacht ausgedrückt, bis zu den Bildschirmkoordinaten gibt es noch ein paar Zwischenschritte, die sparen wir uns aber). Die orthographische Projektion verwendet man im Allgemeinen, wenn man in 2D arbeiten möchte, wie z.B. in alten NES-Spielen. Die perspektivische Projektion verwendet man für alle Spiele, die einen 3D-Eindruck verwenden wollen. In der Schule sollten die meisten von euch schon mal Fluchtpunkt-Zeichnungen gemacht haben, genau dasselbe Prinzip verwendet auch die perspektivische Projektion, nur mathematisch ausformuliert.

Beginnen wir mit der orthographischen Projektion. Für jede Art von Projektion brauchen wir eine Projektionsfläche, in unserem Fall ist das der Bildschirm. Die orthographische Projektion nimmt einfach jeden Vertex her und ignoriert dessen z-Koordinate. Die x- und y-Koordinaten werden mehr oder minder so übernommen, wie sie sind. Geometrisch kann man sich das so vorstellen, dass von jedem Vertex eine Linie ausgeht, die die Projektionsfläche schneidet. Diese Linien sind alle parallel zueinander und normal, d.h. in einem 90 Grad Winkel zur Projektionsfläche. Die Schnittpunkte der Linien mit der Projektionsfläche ergeben die finalen projizierten Punkte. Ein Bild sagt mehr als tausend Worte:

Datei:ortho.png

Es ist also vollkommen unerheblich, wie weit ein Punkt von der Projektionsfläche entfernt ist. Die orthographische Projektion werden wir für all unsere 2D-Bedürfnisse verwenden. Wir konfigurieren sie so, dass wir direkt in Bildschirmkoordinaten arbeiten können. Dazu verwenden wir die Klasse GLU, die eine statische Methode namens glOrtho2D besitzt. Um das gewünschte 2D-Koordinaten-System zu bekommen, rufen wir folgendes auf:

gl.glMatrixMode( GL10.GL_PROJECTION );
gl.glLoadidentity();
GLU.glOrtho2D( gl, 0, activity.getViewportWidth(), 0, activity.getViewportHeight() )

Zuerst sagen wir OpenGL, dass wir die Projektions-Matrix ab jetzt bearbeiten wollen. Projektionen sind Transformationen von Vertices und werden in OpenGL über Matrizen abgebildet. Jeder Vertex, den wir an OpenGL schicken, wird mit ein paar Matrizen multipliziert, um seine finale Position am Bildschirm zu errechnen. Die Projektionsmatrix ist eine dieser Matrizen. Wir brauchen uns aber Gott sei Dank hier nicht mit Matrizen direkt herumschlagen. Als nächstes laden wir eine Einheits-Matrix. Man stelle sich hier einfach vor, dass der Inhalt der Projektions-Matrix dadurch gelöscht wird und die Matrix keinen Einfluss auf unsere Vertices hat. Die Multiplikation mit dieser Matrix ergibt denselben Vektor. Abschließend verwenden wir glOrtho2D, welches eine orthographische Projektions-Matrix lädt. Die Parameter geben dabei an, wie groß die Projektionsfläche ist (stimmt so nicht ganz). Wir geben hier den gesamten Bildschirm an, die Angaben sind in Pixel. Ab nun können wir die Vertex Positionen in Bildschirmkoordinaten angeben, wobei das Koordinaten-System wie folgt im Portrait- und Landscape-Modus aussieht:

Datei:ortho2.png

Zur Veranschaulichung hier noch das Ganze mit einem Mesh, das wir in diesem neuen Koordinaten-System so definieren:

mesh = new Mesh( gl, 3, false, false, false );
mesh.vertex( 0, 0, 0 );
mesh.vertex( 50, 0, 0 );
mesh.vertex( 25, 50, 0 );	

Wir erwarten also ein Dreieck, das unten links neben dem Ursprung in Erscheinung tritt. Und das tut es auch (siehe Sample unter [http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/OrthoSample.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/OrthoSample.java]

Datei:orthotri.png

Damit könnten wir jetzt schon fast unser erstes kleines 2D-Spielchen implementieren. Da wir aber moderne Menschen sind, wollen wir etwas in 3D machen. Dazu benötigen wir die perspektivische Projektion.

Die perspektivische Projektion ist um ein Stückchen schwerer zu durchschauen als die orthographische Projektion, funktioniert aber nach einem ähnlichen Prinzip. Wieder schicken wir Linien durch alle Vertices, diesmal jedoch nicht normal zur Projektionsfläche, sondern durch einen Punkt vor der Projektionsfläche (auf der anderen Seite sind die Vertices).

Datei:perspective.jpg

Dieser Punkt ist insofern besonders, als dass er der Position des Auges eines Betrachters in unserer dreidimensionalen Welt entspricht.

Datei:perspective2.gif

Die perspektivische Projektion wird durch mehrere Parameter definiert. Zum einen durch den so genannten Field of View. Dies ist das Sichtfeld das man in der y-Achse bis zur Projektionsfläche abdeckt. Als nächstes gibt es die near und die far Clipping Planes. Die near Clipping Plane ist unserer Projektionsebene, man gibt hier die Entfernung zum Betrachter an. Die far Clipping Plane ist jene Ebene ab der nichts mehr dargestellt wird. Jeder Vertex der hinter dieser Ebene liegt wird nicht gezeichnet. Als letzten Parameter für eine perspektivische Projektion braucht man das Verhältnis zwischen Breite und Höhe der Projektionsebene, auch Aspect Ratio genannt. Diesen errechnen wir aus der Viewport-Größe, die in Pixeln angegeben ist. All diese Parameter gemeinsam definieren einen Sichtkegel, der vorne durch die near Clipping Plane und hinten durch die Far Clipping Plane begrenzt ist. Auch ist er oben und unten, sowie links und rechts begrenzt. Diesen Sichtkegel nennt man View Frustum. Ganz schön viel Information auf einmal, schauen wir uns an, wie einfach das in OpenGL geht:

gl.glMatrixMode( GL10.GL_PROJECTION );
gl.glLoadIdentity();
float aspectRatio = (float)activity.getViewportWidth() / activity.getViewportHeight();
GLU.gluPerspective( gl, 67, aspectRatio, 1, 100 );

Wieder sagen wir OpenGL, dass wir die Projektions-Matrix ändern wollen und laden dann eine Einheits-Matrix. Als nächstes berechnen wir den aspectRatio als Viewport Breite durch Viewport Höhe. Dieser Wert ist eine Dezimalzahl, daher der Cast auf (float). Zu guter Letzt verwenden wir wieder GLU und dessen Methode gluPerspective. Der erste Parameter ist die GL Instanz, der zweite Parameter das Field of View in Grad, wobei 67 Grad ungefähr dem Sichtfeld nach oben und unten eines Menschen entsprechen. Der nächste Parameter gibt die Distanz zur near Clipping Plane an, hier setzen wir ihn auf 1. Der letzte Parameter gibt die Distanz zur far Clipping Plane an, hier 100, und wir sind auch schon fertig mit der Konfiguration der perspektivischen Projektion.

Jetzt stellt sich uns die Frage, wo in unserem 3D Koordinaten-System sich der Betrachter befindet. Wir erinnern uns, die positive x-Achse zeigt nach rechts, die positive y-Achse nach oben und die positive z-Achse aus dem Bildschirm heraus. Die negative z-Achse zeigt somit in den Bildschirm hinein. Der Betrachter befindet sich im Ursprung, also an den Koordinaten (0,0,0) und schaut gerade entlang der negativen z-Achse. Die near Clipping Plane befindet sich damit an der z-Koordinate -1, die far Clipping Plane an der z-Koordinate -101. Für unser Mesh bedeutet das, dass wir es auf z irgendwo zwischen -1 und -100 ansiedeln müssen. Hier ein Beispiel mit zwei Dreiecken, eines auf z=-2 und ein zweites auf z=-5. Beide Dreiecke haben die Selbe Größe.

mesh = new Mesh( gl, 6, true, false, false );				
mesh.color( 0, 1, 0, 1 );
mesh.vertex( 0f, -0.5f, -5 );
mesh.color( 0, 1, 0, 1 );
mesh.vertex( 1f, -0.5f, -5 );
mesh.color( 0, 1, 0, 1 );
mesh.vertex( 0.5f, 0.5f, -5 );		
mesh.color( 1, 0, 0, 1 );
mesh.vertex( -0.5f, -0.5f, -2 );
mesh.color( 1, 0, 0, 1 );
mesh.vertex( 0.5f, -0.5f, -2 );
mesh.color( 1, 0, 0, 1 );
mesh.vertex( 0, 0.5f, -2);

Das erste Dreieck ist ein wenig nach rechts verschoben und liegt hinter dem zweiten Dreieck. Die Dreiecke werden von OpenGL auch in dieser Reihenfolge gezeichnet, wodurch das hintere grüne Dreieck vom vorderen roten Dreieck überdeckt wird:

Datei:perspectivetri.png

Wie zu erwarten, erscheint das grüne Dreieck kleiner als das rote, da es ja auch weiter entfernt ist. Wir haben somit den Schritt in die dritte Dimension geschafft! Den Sample Code findet ihr unter [[http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/PerspectiveSample.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/PerspectiveSample.java]

Kamera, Z-Buffer und wie lösche ich den Schirm

Eine Kamera, die man nicht bewegen kann, ist äußerst langweilig. Zum Glück ist es mit Hilfe der Klasse GLU extrem einfach, das zu ändern. Dazu müssen wir uns aber zuerst vor Augen führen, wie eine Kamera funktioniert. Zum einen hat sie natürlich eine Position in unserer 3D-Welt. Auch muss sie eine Richtung besitzen, in die sie schaut. Aus dem Vektor-Kapitel wissen wir, wie wir das abbilden können. Ein Baustein fehlt noch: der so genannte Up-Vektor. Dieser definiert die y-Achse der Kamera, die Richtung definiert die z-Achse der Kamera und die x-Achse können wir leicht über das so genannte Kreuz-Produkt aus Up- und Richtungsvektor errechnen. Das brauchen wir aber alles gar nicht, da uns das die GLU-Klasse abnimmt. Zum Verständnis des Up-Vektors: man stelle sich vor ein Pfeil ragt einem senkrecht aus dem Kopf. Das ist der Up-Vektor. Neigt man sein Haupt nun nach rechts oder links, ändert sich auch dieser Up-Vektor und mit ihm der Winkel unter dem man das Bild sieht. Aus mathematischer Sicht sei noch angemerkt, dass dieser Up-Vektor und der Richtungsvektor Einheitsvektoren sind und normal aufeinander stehen, also im 90-Grad-Winkel.

Damit wir unsere Welt aus der Sicht der Kamera sehen, müssen wir wieder eine Matrix von OpenGL bemühen. Diese nennt sich die Model-View-Matrix. Der View-Teil bezeichnet dabei den Umstand, dass man in dieser Matrix die Kamera-Matrix (die sich aus den oben genannten Eigenschaften der Kamera ergibt) ablegt. Ein Vertex wird zuerst durch die Model-View-Matrix transformiert (per Multiplikation) und dann mit der Projektions-Matrix multipliziert, um seine finale Position zu bestimmen. Schauen wir uns also an, wie wir diese Model-View-Matrix mit GLU so setzen können, dass wir unsere Welt aus der Sicht des Kamera sehen:

gl.glMatrixMode( GL10.GL_MODELVIEW );
gl.glLoadIdentity();
GLU.glLookAt( gl, positionX, positionY, positionZ, zentrumX, zentrumY, zentrumZ, upX, upY, upZ );

Die ersten beiden Zeilen kennen wir ja schon. In der ersten sagen wir jedoch, dass wir die Model-View-Matrix verändern möchten. In der dritten Zeile definieren wir unsere Kamera aus der GLU dann eine Matrix errechnet und die Model-View-Matrix auf diese setzt. Der erste Parameter ist wieder die GL-Instanz. Die nächsten drei Parameter geben die Koordinaten der Kamera an. zentrumX, zentrumY und zentrumZ geben einen Punkt in der Welt an, auf den die Kamera blicken soll. Nimmt man diesen Punkt und subtrahiert man davon die Kamera-Position vektoriell, erhält man die Richtung der Kamera und damit deren z-Achse. Die letzten drei Parameter geben den oben beschriebenen Up-Vektor an. Dieser muss ein Einheits-Vektor sein, also die Länge 1 besitzen, sonst können komische Ergebnisse auftreten.

Ziehen wir unsere Szene mit dem roten und dem grünen Dreieck aus dem letzten Beispiel heran. Wir wollen die Kamera jetzt hinter das grüne Dreieck positionieren (also z<-5) und sie in Richtung Ursprung sehen lassen. Die Neigung lassen wir dabei normal, d.h. der Up-Vektor schaut nach oben (0,1,0):

 gl.glMatrixMode( GL10.GL_MODELVIEW );
 gl.glLoadIdentity();
 GLU.glLookAt( 0, 0, -7, 0, 0, 0, 0, 1, 0 );

Das Ergebnis sieht so aus (Sample-Code unter [http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/CameraSample.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/CameraSample.java] )

Datei:camera.png

Da scheint etwas schief gegangen zu sein. Eigentlich dürfte das rote Dreieck ja das grüne nicht überdecken, tut es aber. Das Problem: Im Mesh haben wir das rote Dreieck nach dem grünen definiert und in der Reihenfolge werden sie auch gezeichnet. Betrachten wir das ganze von vorne, stimmt alles. Von unten passt es aber nicht mehr. Was tun? In Abhängigkeit von der Blickrichtung die Ordnung der Dreiecke ändern? Was macht man dann bei sich überlappenden Dreiecken, wo die Ordnung nicht eindeutig ist?

Für all diese Probleme gibt es eine Lösung mit dem klingenden Namen [[http://de.wikipedia.org/wiki/Z-Buffer Z-Buffer]. Dieser ist quasi ein Zusatz zum Frame Buffer (wir erinnern uns, dort werden alle Pixel gespeichert) und besitzt die Selbe Größe. Jedes Pixel eines gezeichneten Dreiecks besitzt neben seiner x- und y-Koordinate nach der Transformation mit der Projektions- und Model-View-Matrix auch eine z-Koordinate. In den Z-Buffer schreibt OpenGL genau diese z-Koordinate und macht noch etwas besonders schlaues: bevor es überhaupt ein Pixel zeichnet, prüft es, ob im Z-Buffer bereits ein Pixel existiert, der näher an der Kamera liegt. Ist dies der Fall, braucht OpenGL den aktuellen Pixel nicht schreiben, da er ja hinter dem aktuellen Pixel liegt. Alles, was wir also tun müssen, ist diesen Z-Buffer einzuschalten und das geht so:

gl.glEnable(GL10.GL_DEPTH_TEST);

Dazu müssen wir aber noch etwas tun. Und zwar den Z-Buffer in jedem Frame löschen. Und wenn wir schon dabei sind, dann können wir auch gleich den Frame Buffer mitlöschen. Wie das geht, ist im Folgenden zu sehen.

gl.glClear(GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT);

Wer auch noch die Farbe bestimmen will, die im Frame Buffer gelöscht werden soll, kann dies folgendermaßen tun:

gl.glClearColor( red, green, blue, alpha );
 

Der Frame und der Z-Buffer sollten möglichst in jedem Frame gelöscht werden. Normalerweise mache ich das immer ganz am Anfang des Rendering, damit ich das nicht vergesse. Schauen wir uns an in folgendem Bild an, wie unser Dreieck-Sortierproblem jetzt aussieht.

Datei:zbuffer.png

Ausgezeichnet! Und all das mit nur zwei zusätzlichen Befehlen. Mit dem Z-Buffer kommen aber auch Probleme:

  • Wenn zwei Dreiecke in derselben Ebene liegen und überlappen, kommt es zum so genannten Z-Fighting. Dies tritt vor allem auf, wenn man beim Rendern mit orthographischer Projektion vergisst, den Z-Buffer mit glDisable(GL10.GL_DEPTH_TEST) auszuschalten. Wir müssen immer daran denken, das vor dem Zeichnen von 2D-Elementen zu machen!
  • Das Problem der Sortierung wird bei transparenten Dreiecken, also bei solchen, durch die der Hintergrund etwas zu sehen ist, nicht gelöst. Man stelle sich ein Dreieck vor, das hinter einem anderen in der Szene liegt. Das verdeckende Dreieck ist transparent und wird vor dem hinteren Dreieck gerendert. Ergebnis: das hintere Dreieck kann nicht durchscheinen, da seine Pixel gar nicht erst in den Frame Buffer geschrieben werden. Im Z-Buffer befinden sich ja schon die Werte für das vordere Dreieck, das näher an der Kamera ist. Dieses Problem löst man im Allgemeinen, indem man zuerst alle nicht-transparenten Objekte zeichnet, dann alle transparenten Objekte über die Distanz zur Kamera sortiert und in der sortierten Reihenfolge rendert.

Beachtet man diese beiden Probleme, steht dem vergnüglichen Gebrauch des Z-Buffers nichts im Weg!

Licht und Schatten

3D alleine macht noch kein 3D-Gefühl. Das menschliche Auge verwendet nicht nur das stereoskopische Sehen zum Abschätzen von Tiefe, sondern auch andere Hinweise und hier vor allem Licht und Schatten.

OpenGL bietet hier einiges an Möglichkeiten, zumindest was Licht betrifft. Schattenwurf, wie wir es kennen, ist nicht direkt in OpenGL inkludiert, kann aber ebenfalls simuliert werden. Mit OpenGL ES ist dies jedoch zu rechenaufwändig, wir werden uns daher nur um das Licht kümmern. Schatten bekommen wir in der Sparversion: vom Licht abgewendete Seiten sind dunkler.

Um OpenGL dazu zu bewegen, Licht zu simulieren, müssen wir dieses einfach anknipsen:

gl.glEnable(GL10.GL_LIGHTING);

Das Ausschalten funktioniert analog:

gl.glDisable(GL10.GL_LIGHTING);

Für unsere 2D-Elemente werden wir kein Licht brauchen. Wir wollen aber die vollen Farben haben. Darum müssen wir vor dem Zeichnen der 2D-Elements das Licht auch ausschalten.

OpenGL kann verschiedene Lichtarten und Lichtquellen simulieren. Wo liegt hier der Unterschied? Als Lichtarten gelten ambientes Licht, diffuses Licht, spekulares Licht und emissives Licht (streng genommen keine Lichtart). Ambientes Licht kommt aus allen Richtungen und hat keine bestimmte Quelle. Es handelt sich um jene Photonen, die schon tausende Male von einem Objekt reflektiert wurden und so für einen "Grundlichtpegel" sorgen. Diffuses Licht geht von einer bestimmten Lichtquelle aus. Es wird in alle möglichen Richtungen reflektiert. Das ist so, weil die meisten Objekte, die Licht reflektieren, feine Unebenheiten aufweisen. Spekulares Licht hingegen wird scharf reflektiert, z.B. auf einem Spiegel und bilden an einem bestimmten Punkt am Objekt ein so genanntes Highlight, einen überbeleuchteten Punkt. Emissives Licht ist Licht, das vom bestrahlten Objekt selbst ausgeht. Das folgende Bild zeigt alle vier Typen: ambient, diffuse, spekular und emissiv.

Datei:lighttypes.png

Wir werden uns nur mit ambientem und diffusem Licht in OpenGL beschäftigen. Spekulares Licht benötigt ein sehr fein aufgelöstes Mesh und damit viele Dreiecke, um richtig zur Geltung zu kommen. Emissives Licht können wir auch über die Farbe des Meshes simulieren, was in der Regel einfacher ist.

Als Lichtquellen gelten Punktlichtquellen, wie etwa eine Lampe, deren Strahlen radial ausstrahlen, direktionale Lichtquellen, wie etwa die Sonne, deren Strahlen aufgrund der Entfernung alle so gut wie parallel bei uns auftreffen und Spotlichtquellen, wie ein gerichteter Scheinwerfer, der einen Lichtkegel bildet. Punktlichtquellen und Spotlichtquellen besitzen eine Position im Raum. Eine direktionale Lichtquelle wird in OpenGL als unendlich weit entfernt angenommen und hat deswegen nur eine Richtung. Wir werden uns nur mit Punktlichtquellen und direktionalen Lichtquellen beschäftigen. Für Spotlichtquellen gilt wieder ähnliches, wie für spekulares Licht, sie benötigen hoch aufgelöste Meshes, um zur Geltung zu kommen. Jeder Lichttyp imitiert ambientes, diffuses und spekulares Licht. Wenn wir eine Lichtquelle definieren, müssen wir für jeden Lichttypen die Farbe angeben, die die Lichtquelle für diesen Typen imitiert. OpenGL ES kann insgesamt 8 Lichtquellen zugleich simulieren. Diese Lichtquellen werden von 0 bis 7 durchnummeriert und werden mit den Konstanten GL10.GL_LIGHT0 bis GL10.GL_LIGHT7 identifiziert. Schauen wir uns zuerst an, wie man die Farben der Lichttypen eines Lichtes definiert:

float lightColor[] = {1, 1, 1, 1.0};
float ambientLightColor[] = { 0.2f, 0.2f, 0.2f, 1.0 };
gl.glLightfv(GL.GL_LIGHT0, GL10.GL_AMBIENT, lightColor, 0 );
gl.glLightfv(GL.GL_LIGHT0, GL10.GL_DIFFUSE, lightColor, 0 );
gl.glLightfv(GL.GL_LIGHT0, GL10.GL_SPECULAR, lightColor, 0 );

In der ersten Zeile basteln wir einen Array mit weißer Lichtfarbe. Für die ambiente Komponente definieren wir ein dunkles Grau in Zeile 2. Zeilen 3 bis 5 setzt dann die Farbe für jeden Lichttypen der Lichtquelle 0. So einfach geht das. Natürlich kann man für jeden Lichttyp eine verschiedene Farbe angeben, hier kann man experimentieren.

Ob die Lichtquelle ein direktionales Licht oder ein Punktlicht ist, definiert man ebenfalls über die Methode glLightfv. Anstatt als zweiten Parameter den Lichttyp anzugeben (z.B: GL10.GL_AMBIENT) verwendet man aber die Konstante GL10.GL_POSITION. Auch übergeben wir wieder einen float-Array mit vier Elementen. Ist das letzte Element gleich 0, bedeutet das für OpenGL, dass wir ein direktionales Licht haben wollen. Die drei ersten Elemente geben dann die negative Richtung des Lichts an. Diese Richtung muss ein Einheitsvektor sein! Und es kann als die Position einer Lichtquelle gelten. Die Richtung ist dann der Vektor von der Lichtquelle zum Ursprung. Sehr verwirrend. Ist das vierte Element gleich 1, heißt das, dass wir ein Punktlicht wollen. Die ersten drei Elemente im Array entsprechen dann der Position des Lichts in der Welt.

Ein direktionales Licht von links würde dem zur Folge so definiert:

float[] direction = { 1, 0, 0, 0 };
gl.glLightfv(GL.GL_LIGHT0, GL10.GL_POSITION, direction, 0 );

Wie man eine Punktlichtquelle direkt über dem Ursprung angibt, ist im nächsten Code-Stück zu sehen.

float[] position = { 0, 10, 0, 1 };
gl.glLightfv(GL.GL_LIGHT0, GL10.GL_POSITION, position, 0 );

Wie Licht von einem Objekt reflektiert wird, hängt nicht nur vom Lichttyp und der Lichtquelle ab, auch das Material des Objektes spielt dabei eine Rolle. OpenGL ES hat einen relativ guten Mechanismus, um das Material eines Objekts zu definieren. So gut dieser ist so, so langsam ist er leider auch. Wieder müssen wir für jeden Lichttyp eine Farbe definieren. Dies würden wir mit der Methode glMaterialfv machen. Diese ist aber, wie gesagt, extrem langsam. Wir nehmen hier eine Abkürzung und verwenden eine spezielle Methode von OpenGL ES.

gl.glEnable(GL_COLOR_MATERIAL);

Dies weist OpenGL ES an, dass es anstatt eines definierten Materials einfach die Farbe des Vertex hernehmen soll und dieses für die ambiente und diffuse Komponente verwenden soll. In der Regel kommt man damit, vor allem auf kleinen Bildschirmen, locker durch.

Wer sich noch an das Kapitel Mesh- und Textur-Klasse erinnern kann, denkt vielleicht jetzt an die Normalen, die wir pro Vertex gleich wie Farbe oder Textur-Koordinaten angeben können. Diese brauchen wir auch unbedingt, wenn wir OpenGLs Beleuchtungsmodell verwenden wollen. Was ist also so eine Vertex-Normale? Dazu ein kleines Bild.

Datei:normals.jpg

An jeder Ecke des Würfels sitzen 3 Vertices. Wenn wir darüber nachdenken, wird schnell klar warum: jede Seite, die auf diese Ecke trifft, hat ein Dreieck an dieser Stelle. Da wir es nicht besser wissen, würden wir für den Würfel insgesamt 6 * 2 = 12 Dreiecke haben und damit 6 * 2 * 3 = 36 Vertices. Jeder Vertex hat nun eine Normale. Diese Normale ist normal zur Ebene, in der das Dreieck liegt und ragt aus der Vorderseite des Dreiecks. OpenGL verwendet diese Normale, um den Winkel des Vertices zur Lichtquelle zu berechnen. Darum müssen wir in Meshes unbedingt angeben, was wir beleuchten wollen. Im Code zur Mesh-Klasse könnt ihr euch anschauen, wie man die Normale eines Vertex OpenGL übergibt. Der Mechanismus ist 1:1 derselbe, wie bei Farben und Textur-Koordinaten, darum gehe ich hier nicht noch mal gesondert drauf ein. Zur Übung versuchen wir einfach schnell ein Mesh zu machen, das den drei in der obigen Zeichnung eingezeichneten Dreiecken entspricht. Wir werden es so definieren, dass das dunkelste Dreieck in der x-y-Ebene liegt.

mesh = new Mesh( gl, 9, false, false, true );
mesh.normal( 0, 0, 1 );
mesh.vertex( 1, 0, 0 );
mesh.normal( 0, 0, 1 );
mesh.vertex( 1, 1, 0 );
mesh.normal( 0, 0, 1 );
mesh.vertex( 0, 1, 0 );
mesh.normal( 1, 0, 0 );
mesh.vertex( 1, 0, 0 );
mesh.normal( 1, 0, 0 );
mesh.vertex( 1, 0, -1);
mesh.normal( 1, 0, 0 );
mesh.vertex( 1, 1, 0 );
mesh.normal( 0, 1, 0 );
mesh.vertex( 1, 1, 0 );
mesh.normal( 0, 1, 0 );
mesh.vertex( 1, 1, -1 );
mesh.normal( 0, 1, 0 );
mesh.vertex( 0, 1, 0 );

Pfuh, ganz schön viel Code für drei Dreiecke. Wir werden da später Abhilfe schaffen. Beim Rendern müssen wir jetzt ein paar Dinge erledigen. Zum einen die Beleuchtung einschalten, danach die Lichtquelle definieren. Dann die Lichtquelle einschalten und vor dem Rendern des Mesh noch Color-Material aktivieren. Als Lichtquelle nehmen wir eine weiße Direktionale, die von Rechts oben kommt (-1, -1, 0):

gl.glEnable( GL10.GL_LIGHTING );
float[] lightColor = { 1, 1, 1, 1 };
float[] ambientLightColor = {0.2f, 0.2f, 0.2f, 1 };
gl.glLightfv( GL10.GL_LIGHT0, GL10.GL_AMBIENT, ambientLightColor );
gl.glLightfv( GL10.GL_LIGHT0, GL10.GL_DIFFUSE, lightColor );
gl.glLightfv( GL10.GL_LIGHT0, GL10.GL_SPECULAR, lightColor );
float[] direction = { -1 / (float)Math.sqrt(2), -1 / (float)Math.sqrt(2), 0, 0 };
gl.glLightfv( GL10.GL_LIGHT0, GL10.GL_POSITION, direction );
gl.glEnable( GL10.GL_LIGHT0 );
gl.glEnable( GL10.GL_COLOR_MATERIAL );
mesh.render(PrimitiveType.Triangles);

Wieder ein Höllen-Aufwand. Wir haben auch ein paar Faux Pas geschossen. Zum einen würden wir so im Main Loop permanent zwei neue float-Arrays instanzieren. Das würde irgendwann den Garbage Collector verstimmen, der sich dann ein paar hundert Millisekunden Auszeit nimmt, um aufzuräumen. Wir werden im Sample-Code die beiden Arrays zu Klassen Member unserer Sample Activity machen und somit nur einmal instanzieren. Zweitens müssen wir eine Lichtquelle nicht immer neu definieren. Wenn sich diese über den Verlauf nicht ändert, reicht es deren Lichttyp-Farben nur einmal anzugeben. Die Position/Richtung der Lichtquelle müssen wir aber immer nach dem Aufruf von GLU.glLookAt machen, da die Position sonst in einem anderen Koordinaten-System definiert wird. Verwirrend. Wer sich übrigens fragt warum wir die direction-Elemente durch Math.sqrt(2) dividieren: Hier normalisieren wir den Richtungsvektor! ( (-1, -1, 0)' = [-1 / |(-1, -1, 0)|, -1 / |(-1, -1, 0), 0 / |(-1, -1, 0)] ).

Um die Situation im Bild oben nachzustellen werden wir auch die Kamera Position und Richtung entsprechend setzen. Den genauen Code könnt ihr hier sehen [http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/LightSample.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/LightSample.java]. Das Ergebnis sieht so aus:

Datei:lightsample.png

Eine Punktlichtquelle würde komplett analog dazu definiert und eingesetzt werden, mit dem Unterschied im vierten Element im Positions -Array. Und wieder ein Geheimnis von OpenGL gelüftet!

Transformationen

Jetzt wird's noch mal kurz mathematisch. Vielleicht hat sich der eine oder andere bereits gefragt, wie man denn ein Mesh an verschiedenen Positionen mehrere Male zeichnen kann. Schließlich sieht man das ja auch in anderen Spielen, z.B. in einem Echtzeitstrategiespiel, wo derselbe Einheitentyp mehrere Male gezeichnet wird, nur an verschiedenen Positionen und in unterschiedlicher Ausrichtung. In OpenGL verwendet man dazu wieder Matrizen, genauer, die bereits erwähnte Model-View-Matrix. Hier erklärt sich auch der erste Teil des Namens der Matrix: Model steht für die Möglichkeit ein Model (Mesh) in der Welt zu verschieben, zu rotieren und zu skalieren (größer und kleiner machen).

Unter einer Transformation versteht man die Verschiebung (Translation), Skalierung und Rotation von Vertices in der Welt. Dabei kann man mehrere solcher Transformationen über die Model-View-Matrix kombinieren, z.B. zuerst skalieren, dann rotieren und zum Schluss verschieben. Mathematisch gesehen entspricht das der Multiplikation von Matrizen. Für jede Transformation wird eine Matrix erstellt, diese werden dann in der gewünschten Reihenfolge der Transformationen miteinander multipliziert. Wieder müssen wir uns zum Glück nicht direkt mit Matrizen herumschlagen, OpenGL bietet uns verschiedene Methoden die das erstellen und multiplizieren der Matrizen für uns erledigt.

Fangen wir mit der Translation an. Diese wird über einen Translations-Vektor angegeben der zu allen Vertices, die man rendert hinzuaddiert wird.

Datei:translation.png

In OpenGL ES erreichen wir das, indem wir folgende Methode verwenden:

gl.glTranslatef( x, y, z );

Wie unschwer zu erkennen, handelt es sich bei den drei Parametern um den Translations-Vektor. Diese Methode erstellt intern eine Translations-Matrix und multipliziert die aktuell aktive Matrix damit, z.B. die Model-View-Matrix die wir über glMatrixMode ausgewählt haben.

Die Skalierung multipliziert jede Komponente der Vertex Position mit einem Skalierungsfaktor.

Datei:scaling.png

OpenGL stellt dafür folgende Methode zur Verfügung:

gl.glScalef( scaleX, scaleY, scaleZ );

Für jede der drei Achsen gibt es einen eigenen Skalierungsfaktor. Wieder wird intern eine Matrix erstellt, mit den Werten für die Skalierung befüllt und dann mit der aktuell aktiven Matrix multipliziert.

Die Rotation ist ein wenig schwerer zu verstehen. Gedreht wird immer um den Ursprung. Gleichzeitig müssen wir eine Achse angeben, (die implizit durch den Ursprung geht) um die sich die Vertices drehen sollen.

Datei:rotation.png

Die OpenGL Methode dafür:

gl.glRotatef( angle, axisX, axisY, axisZ );

Angle gibt den Winkel in Grad an, axisX bis axisZ ist die Rotationsachse, um die gedreht werden soll. Im obigen Beispiel ist diese (0, 0, 1). Wie bei vielen anderen Dingen, muss die Rotationsachse ein Einheitsvektor sein.

Diese drei Transformationen können wir beliebig miteinander kombinieren, z.B. verschieben, rotieren, skalieren usw. Als aktive Matrix wählen wir für diese Transformationen immer die Model-View-Matrix über glMatrixMode. Schauen wir uns einmal an was die Kombination von Translation und Rotation bewirkt:

Datei:transform1.png

Zuerst verschieben wir das Dreieck ein wenig nach rechts, dann rotieren wir um die positive z-Achse. Wenn wir das ganze umdrehen, sieht das Ergebnis so aus:

Datei:transform2.png

Eine komplett andere Wirkung. Wir müssen bei der Anwendung von Transformationen immer auf die Reihenfolge schauen. Das erste Beispiel würden wir mit OpenGL so realisieren:

gl.glRotatef( 45, 0, 0, 1 );
gl.glTranslatef( 2, 0, 0 );

Hm, sollte das nicht umgekehrt sein? Wir wollen ja zuerst verschieben und dann rotieren. OpenGL ist da anderer Ansicht, die letzte Transformation, die wir über die Transformations- Methoden angeben, ist immer die erste, die auf die Vertices wirkt. Dies resultiert aus der Art, wie OpenGL Matrizen multipliziert und soll uns hier nicht weiter kümmern. Wir müssen uns nur den Umstand merken, dass wir Transformationen immer in der umgekehrten Reihenfolge ausführen müssen.

Was ich bis jetzt verschwiegen habe, ist der Zusammenhang zwischen Transformationen und der Kamera. Wir befüllen in 3D ja die Model-View-Matrix per GLU.gluLookAt bereits mit der Kamera-Matrix. Zeichnen wir nun mehrere Objekte mit jeweils eigenen Transformationen, müssten wir die Kamera-Matrix vor dem Zeichnen eines Objektes jedes Mal neu setzen, da wir ja die Model-View-Matrix beim vorhergehenden Objekt überschrieben haben. Um diese recht kostspielige Operation zu vermeiden, gibt es zwei Befehle:

gl.glPushMatrix();
gl.glPopMatrix();

In Wirklichkeit gibt es für jede Matrix in OpenGL (Projektion, Model-View) nicht nur eine Matrix, sondern einen Stack an Matrizen. Mit den oben vorgestellten Methoden manipulieren wir immer die Spitze des Stack. Die beiden Methoden glPushMatrix() und glPopMatrix() erlauben es uns, die über glMatrixMode aktuell selektierte Matrix auf den Stack zu legen, bzw. die zuletzt auf den Stack gelegte Matrix wieder zur aktuellen Matrix zu machen. Beim pushen der Matrix wird eine Kopie angelegt, die aktuelle Matrix bleibt dieselbe.

In Spielen geht man in der Regel so vor: jedes Objekt in der Spielwelt hat eine Orientierung (also eine Richtung, in die es schaut) und eine Position. Die Meshes für die Objekte sind immer um den Ursprung definiert. Zeichnet man nun die Objekte, lädt man zu allererst die Kamera-Matrix in die Model-View-Matrix. Beim Zeichnen jedes Objektes pushen wir die Model-View-Matrix, damit wir eine Kopie der Kamera-Matrix auf dem Stack haben. Dann multiplizieren wir die Transformationen auf die Model-View-Matrix und zeichnen das Mesh des Objekts. Das Objekt wird damit richtig transformiert. Dann popen wir die Model-View-Matrix wieder vom Stack. Auf diese Weise beinhaltet dieser wieder nur die Kamera-Matrix. Diesen Prozess wiederholen wir für alle Objekte, die wir zeichnen. So werden wir das dann auch in unserem Space Invaders-Klon machen. Ein Sample, das ein wenig vorgreift, (verwendet den Obj MeshLoader) findet ihr unter [http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/MultipleObjectsSample.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/MultipleObjectsSample.java]. Das Sample zeigt auch, wie man einen Applikation-Fullscreen macht und die Orientierung fixiert. Das Ganze ist im untenstehenden Bild zu sehen.

Datei:multiple.png

Damit haben wir den letzten großen Brocken, was OpenGL betrifft, abgearbeitet. Hier gilt das Gleiche, wie für Licht: experimentieren, experimentieren, experimentieren. Um Transformationen zu verstehen, muss man sie in Aktion sehen. Als kleine Aufgabe könnt ihr ja eines der Samples hernehmen und ein um die y-Achse rotierendes Dreieck produzieren. Dazu müsst ihr nur in jedem Frame glRotatef mit dem aktuellen Winkel aufrufen. Den Winkel muss man natürlich in jedem Frame erhöhen und, wenn er größer als 360 ist, wieder auf 0 zurücksetzen.

Text zeichnen

Für die Anzeige von Scores und Ähnlichem brauchen wir eine Möglichkeit, Text zu zeichnen. Dies ist in OpenGL von Haus aus nicht integriert, schließlich kann OpenGL ohne unser zutun ja wirklich nur Dreiecke zeichnen. Die herkömmliche Herangehensweise für die Implementierung von Text in OpenGL funktioniert aber relativ einfach. Das Betriebssystem bietet in der Regel Möglichkeiten, um an die Bitmaps für einzelne Characters, sprich Zeichen, einer bestimmten Schriftart zu kommen. Alles, was wir machen müssen, ist diese Bitmaps in eine Textur zu zeichnen und uns zu merken, wo in der Textur wir die Bitmap für einen Character finden. Wollen wir Text zeichnen, müssen wir lediglich ein Mesh erstellen, die für jeden Character im String zwei Dreiecke besitzt, die ein Viereck bilden. Das Viereck muss genauso groß sein, wie die Bitmap für den Character. Danach mappen wir diese beiden Dreiecke so mit der Character-Textur, das diese genau den Ausschnitt der Textur verwenden, wo die Bitmap des Characters hingezeichnet wurde. Übrigens nennt man die Bitmap für so einen Character auch Glyph. Die Textur ist demzufolge ein so genannter GlyphCache. Um diese ganze mühselige Arbeit ein wenig zu vereinfachen, habe ich eine Klasse Font sowie eine Klasse Text geschrieben, die uns diese ganze Arbeit abnimmt. Die Klasse Font hat dabei nur ein paar relevante Funktionen:

public class Font
{
   public Font(GL10 gl, String fontName, int size, FontStyle style)
   public Font(GL10 gl, AssetManager assets, String file, int size, FontStyle style)
   public Text newText( GL10 gl )
   public void dispose( )
}
 

Die ersten beiden Methoden sind die Konstruktoren der Klasse. Der erste Konstruktor instanziert einen Font über die Angabe seines Namens. Damit kann man Fonts, die im System installiert sind, instanzieren. Der dritte Parameter gibt die Größe des Fonts in Punkten an, der vierte den Stil des Fonts, also z.B. italic oder bold usw. Der zweite Konstruktor erlaubt das Laden eines TrueType Fonts aus einer Asset-Datei. Dazu geben wir den AssetManager an, den wir von unserer Activity erhalten, sowie den Dateinamen des Font Assets. Die restlichen Parameter entsprechen dem ersten Konstruktor. Die dritte Methode instanziert eine Instanz der Klasse Text. Diese speichert die Dreiecke auf den von uns gewünschten Text. Die letzte Methode gibt den Font und seine Ressourcen (Glyphcache Textur) wieder frei.

Die Klasse Text hat einige Methoden zum formatieren von Text, die wir aber für unseren Space Invaders-Klon nicht brauchen. Hier die wichtigsten Methoden.

public class Text
{
   public void setText( String text );
   public void render( );
} 

Die erste Methode setzt den Text, den wir zeichnen wollen. Intern werden dabei die entsprechenden Dreiecke erstellt, die auf die Glyphcache Textur des Fonts, von dem die Instanz Text kommt, mappen. Die Methode render zeichnet den Text dann, beginnend im Ursprung. Wir können später einfach Transformationen verwenden, um den Text am Bildschirm zu verschieben. Wichtig ist auch, dass wir beim Zeichnen eine orthographische Projektion verwenden, die ein 2D-Pixelkoordinatensystem verwendet. Die Klasse Text kann auch mehrzeiligen Text rendern, diesen linksbündig, zentriert und rechtsbündig ausrichten oder ähnliches. Ein wenig damit herumspielen und man hat den Dreh heraus.

Damit auch nur wirklich die Pixel des Textes gerendert werden, müssen wir auch Blending einschalten. Blending sorgt für Transparenz, jeder Pixel in einer Textur mit einem Alphawert kleiner als 1 wird durchsichtig. Dasselbe gilt auch für Polygone, deren Vertices Farbwerte mit Alphawerten kleiner als 1 haben. Blending schalten wir in OpenGL so ein:

gl.glEnable( GL10.GL_BLEND );
gl.glBlendFunc( GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA );

Der erste Aufruf schaltet Blending ein, der zweite legt fest, wie geblendet wird. Blending ist ein sehr komplexer Themenkreis, deshalb werde ich Blendfunctions hier nicht besprechen. Die oben angegebene Blendfunction reicht für 90% aller Bedürfnisse aus. Sie besagt, dass die Pixel des gerade gezeichneten Dreiecks mit den Pixeln im Framebuffer geblendet werden sollen. Diese Blendfunction brauchen wir für unseren Text, aber auch für das Anzeigen von anderen durchscheinenden Objekten, wie z.B. Explosionen. Zum Ausschalten von Blending reicht ein Aufruf.

gl.glDisable( GL10.GL_BLEND );

Ein komplettes Sample zum Zeichnen von Text unter orthographischer Projektion, findet ihr unter [http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/TextSample.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/TextSample.java].

Datei:textsample.png

Meshes laden

Nachdem wir den Schritt in die dritte Dimension gewagt haben, wäre es natürlich nicht schlecht, eine Möglichkeit zu besitzen, Meshes aus anderen Programmen zu laden. Eines der einfachsten Mesh Formate ist das Wavefront OBJ Format. Ich habe mir die Freiheit genommen, dafür einen einfachen Lader zu schreiben. Dieser kann mit Obj-Dateien umgehen, die nur Dreiecke beinhalten. Baut ihr also eure eigenen Meshes in Wings3D oder Blender, könnt ihr eure Meshes von dort aus in eine Obj-Datei exportieren und mit dem Lader laden. Der Lader besitzt nur eine statische Methode:

Mesh MeshLoader.loadObj( GL10 gl, InputStream in )

Zum Laden übergeben wir also nur einen InputStream auf ein Obj-Asset und erhalten eine Mesh zurück, die fix und fertige ist. Wenn das Mesh Normalen oder Textur-Koordinaten hat, werden diese natürlich mitgeladen und können dann mit einer Lichtquelle bzw. Textur verwendet werden. Ich habe in Wings3D ein kleines Raumschiff gebaut und mit Gimp dafür eine Textur erstellt. Die Obj-Datei und die Textur verwende ich in einem weiteren Sample, das ihr unter [http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/ObjSample.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/ObjSample.java] findet. Es ist im Grunde das Light Sample mit dem Unterschied, dass ich eine Textur und das Mesh aus der Obj-Datei lade. Außerdem habe ich mir erlaubt, hier die Aufgabe aus dem Transformations-Kapitels umzusetzen. Das Schiff dreht sich hübsch. Hier noch ein Screenshot.

Datei:ship.png

Jetzt haben wir aber wirklich alles besprochen, was es zu besprechen gibt. Auf zum Sound!

SoundPool und MediaPlayer

Soundeffekte und Musik werden uns in diesem Kapitel beschäftigen. Für beides stellt uns Android zwei handliche Klassen zur Verfügung: SoundPool für Soundeffekte und MediaPlayer für das abspielen von Musik. Fangen wir mit SoundPool an.

Wie wir uns erinnern sind Soundeffekte Audio-Dateien die wir aufgrund ihrer kleinen Größe vollständig in den Speicher laden. Auch kann ein und derselbe Soundeffekt mehrere Male gleichzeitig abzuspielen sein. Genau diese Aufgaben erledigt für uns der SoundPool. Schauen wir uns an wie, man ihn instanziert.

SoundPool soundPool = new SoundPool( 5, AudioManager.STREAM_MUSIC, 0);

Sehr einfach. Der erste Parameter gibt an, wie viele Soundeffekte der Soundpool maximal gleichzeitig abspielen kann. Hier geht es wirklich nur um das abspielen, laden können wir so viele, wie wir Speicher haben. Der zweite Parameter gibt an, um welchen Stream es sich handelt. Auf Android gibt es verschiedene Kanäle für Audio, z.B. den Klingelton-Stream oder eben den hier gewählten Musik-Stream. Diesen wählen wir im Fall von Soundeffekten immer. Der letzte Parameter hat zurzeit noch keine Funktion und soll laut Dokumentation auf 0 gesetzt werden, was wir auch tun.

Einen Soundeffekt zu laden, geht auch sehr einfach. Wir gehen davon aus, dass wir eine Audio-Datei namens "shot.wav" in unserem Asset Verzeichnis haben. Es wird wie folgt geladen:

AssetFileDescriptor descriptor = getAssets().openFd( "shot.wav" );
int soundID = soundPool.load( descriptor, 1 );

Als erstes benötigen wir einen AssetFileDescriptor, den wir uns für die Audio-Datei in der ersten Zeile holen. Diesen übergeben wir dann in der zweiten Zeile an die Methode SoundPool.load die uns dann die Audio-Datei in den Speicher lädt. Als Rückgabewert erhalten wir, für die gerade geladene Datei, eine ID, die wir später angeben müssen, wenn wir diesen Soundeffekt abspielen wollen. Eine kleine Warnung: Es dauert eine Zeit, bis der SoundPool alle Soundeffekte geladen hat. Das macht er in einem separaten Thread, d.h. in unserem Spiel werden wir davon laufzeittechnisch nichts merken. Was wir aber merken, ist, dass in den ersten paar Sekunden nach dem Laden das Abspielen keinen Effekt hat. Hierfür gibt es leider noch keine Lösung, da ein Spiel aber meistens in einem Menü beginnt und dort keine Soundeffekte verwendet werden, ist das normalerweise kein Problem.

Das Abspielen des eben geladenen SoundEffekts ist dann ebenso simpel:

int volume = (AudioManager)activity.getSystemService(Context.AUDIO_SERVICE).getStreamVolume( AudioManager.STREAM_MUSIC );
soundPool.play(soundID, volume, volume, 1, 0, 1);

Als erstes müssen wir herausfinden, wie laut die Medienlautstärke aktuell ist. Diese Information bekommen wir von der Methode getStreamVolume der Klasse AudioManager, deren Instanz wir wiederum über getSystemService erhalten. Als Stream geben wir wieder den Musik Stream an, da wir ja mit diesem Arbeiten. Den zurückgegebenen Wert, merken wir uns und verwenden ihn in der zweiten Zeile. Hier weisen wir den SoundPool an, den zuvor geladenen Soundeffekt abzuspielen. Dazu geben wir als ersten Parameter die ID an, die wir zuvor erhalten haben, dann die Lautstärke des linken und rechten Kanals. Der vierte Parameter gibt die Priorität an, mit der der Soundeffekt abgespielt werden soll. Der Wert 1 erweist uns hier gute Dienste. Der fünfte Parameter gibt an, ob der Soundeffekt geloopt, also mehrere male hintereinander abgespielt werden soll. Wie geben hier 0 an, da wir das nicht wollen. Der letzte Parameter gibt an, mit welcher Geschwindigkeit der Soundeffekt abgespielt werden soll. 1 bedeutet in normaler Geschwindigkeit, 2 würde doppelte Geschwindigkeit bedeuten und so weiter. Damit wissen wir jetzt, wie wir Soundeffekte abspielen können.

Ein Sample, welches beim berühren des Bildschirms ein Schießgeräusch macht, findet ihr unter [http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/SoundSample.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/SoundSample.java].

Für das Abspielen von Musik verwenden wir den MediaPlayer. Der gibt sich von der Dokumentation her recht kompliziert, ist er aber im Grunde nicht. Schauen wir uns zuerst an, wie wir ihn Instanzieren:

MediaPlayer mediaPlayer = new MediaPlayer( );

Das war wieder einfach. Als nächstes müssen wir dem MediaPlayer sagen, was er abspielen soll. Dafür brauchen wir wieder einen AssetFileDescriptor, wie schon bei den Soundeffekten:

AssetFileDescriptor descriptor = getAssets().openFd( "music.mp3" );
mediaPlayer.setDataSource( descriptor.getFileDescriptor() );
mediaPlayer.prepare();
mediaPlayer.start();

Wir holen uns also den File-Descriptor auf unseren Musik Asset. In der nächsten Zeile setzen wir den MediaPlayer dann von dieser Datei in Kenntnis. In der dritten Zeile geben wir dem MediaPlayer Zeit, sich auf das Abspielen vorzubereiten. Ohne den Aufruf von MediaPlayer.prepare spielt der MediaPlayer nichts! Schließlich starten wir das Playback der Musik.

Der MediaPlayer bietet alle möglichen Methoden zum pausieren, zurückspulen und so weiter. Für unsere Zwecke reicht das einmalige starten aber vollkommen aus. Was wir beim MediaPlayer noch beachten müssen, ist, dass wir ihn beim Pausieren der Applikation wieder freigeben müssen. Ansonsten spielt er einfach weiter. Wir überschreiben dazu die onPause Methode unserer GameActivity:

@Override
protected void onPause( )
{
	super.onPause();
	mediaPlayer.release();
}

Nicht auf den Aufruf von super.onPause() vergessen! Ein Sample, das eine kleine Eigenkomposition spielt, findet ihr unter [http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/MusicSample.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/samples/MusicSample.java].

Das war ein erfrischend kurzes und einfaches Kapitel. Wir haben jetzt alle nötigen Tools zusammengetragen, die wir für die Entwicklung eines kleinen Spiels brauchen. All die hier besprochenen Samples und Klassen findet ihr im Projekt. Ich empfehle euch damit herumzuspielen, da man nur so ein Gefühl für die Dinge erhält. Auch ist das Lesen der hier verlinkten Dokumentationen keine schlechte Idee. The more you know...

Auf zu Space Invaders!

Space Invaders

Wer Space Invaders nicht kennt soll sich zuerst einmal selbst Ohrfeigen. Neben Asteroids war Space Invaders das erste Shot em' up, das kommerziell äußerst erfolgreich war. Ausgezeichnet hat es die, für damalige Verhältnisse, große Menge an Objekten, die gleichzeitig am Bildschirm dargestellt wurden. Das Spielprinzip ist dabei extrem simpel. Als Kommandeur eines kleinen Raumschiffes, gilt es, außerirdische Raumschiffe davon abzuhalten, die Erde zu überrennen. Das schafft man, indem man auf die Raumschiffe schießt, die dann über den Jordan gehen. Einen Klon des Originals kann man unter [http://www.spaceinvaders.de/ http://www.spaceinvaders.de/] spielen, was ich hiermit jedem empfehle. Hier noch ein kleiner Screenshot:

Datei:spaceinvaders.jpg

Bevor wir uns an die Umsetzung des Spiels machen, wollen wir seine einzelnen Teile analysieren, damit wir eine genaue Vorstellung davon haben, was wir überhaupt alles implementieren müssen.

Analyse des Originals

Space Invaders hat ein auf den Bildschirm begrenztes Spielfeld. Es gibt vier Arten von Invaders, die drei, die oben im Screenshot sichtbar sind, sowie ein Ufo, das von Zeit zu Zeit am oberen Bildschirmrand vorbeifliegt. Die Invader sind dabei in einem Netz in gleichen Abständen angeordnet. Jede Reihe besteht aus elf Invadern, insgesamt gibt es fünf Reihen. Die Invader fahren von links nach rechts, danach eine Reihe weit nach unten, dann von rechts nach links. Das ganze wiederholt sich, die Invader kommen dabei immer näher an das Schiff heran. Gleichzeitig schießen die Invader zufallsbasiert hin und wieder. Am unteren Bildschirmrand befinden sich Schildblöcke, die Schüsse der Invader, sowie des Schiffes, abfangen. Die Blöcke werden durch Schüsse zerstört. Sie bilden eine Barriere zwischen dem Schiff und den Invadern, die im Spielverlauf verschwindet. Sobald ein Invader auf Höhe der Blöcke ist, verschwinden die bis dahin verbleibenden Blöcke komplett. Kollidiert ein Invader mit dem Schiff, verliert dieses eines von seinen insgesamt drei Leben. Das Schiff selbst kann immer nur einen Schuss abfeuern. Der nächste Schuss kann erst abgefeuert werden, wenn der erste verschwunden ist. Dies ist der Fall, wenn ein Invader getroffen wird oder der Schuss das Spielfeld verlässt. Hat der Spieler alle Invader auf dem Bildschirm vernichtet, kommt eine neue Welle an Invadern, die schneller sind als die vorhergehenden. Die Blöcke werden restauriert und das ganze beginnt von vorne, bis der Spieler alle Leben verloren hat. Der Abschuss eines Invaders bringt Punkte. Die Anzahl der Punkte ist dabei abhängig vom Typen des Invaders. Wird das Schiff von einem Schuss getroffen, explodiert es, ebenso wie bei einem getroffenen Invader. Es kann dann für einige Sekunde nicht kontrolliert werden. An der Position, an der das Schiff zerstört wurde, respawned es wenig später. Die Kontrolle des Schiffes erlaubt das Steuern nach links und rechts, sowie das Abfeuern eines Schusses, wenn noch kein Schuss des Schiffes im Spielfeld ist.

Aus all dem Gesagten lassen sich die Elemente für das Spiel relativ einfach ableiten:

  • Schiff
  • Invader
  • Explosion
  • Block
  • Schuss

Wir werden uns als nächstes anschauen, wie wir unseren Klon strukturieren. Die hier besprochenen Elemente werden in unseren Klon einfließen, ein paar Dinge werden wir adaptieren (müssen).

Das Spielfeld

Unser Space Invader Klon soll im Grunde seines Herzens nach wie vor ein 2D-Spiel bleiben. Wir werden aus diesem Grund das Spielfeld selbst in unserem dreidimensionalen Raum in die x-z-Ebene verlegen. Im Original wurde das Spielfeld vom Bildschirm selbst begrenzt, da wir uns in einem dreidimensionalen Raum befinden, machen wir diese Begrenzung künstlich. Die untere Grenze des Spielfeldes stellt die X-Achse dar (z=0). Auf dieser werden wir später das Schiff bewegen. Den oberen Rand des Spielfeldes setzen wir auf den z=-15. Links und rechts begrenzen wir das Spielfeld auf x=-13 bzw. x=13. Unser Spielfeld ist damit (13+13)*15 Einheiten groß und liegt in der x-z-Ebene, wobei all unsere z Werte <= 0 und >= -15 sein werden, unsere x Werte >= -13 und <= 13.

Datei:playfield.png

Man beachte, wie gesagt, dass das Spielfeld im negativen z-Bereich liegt. Ein Feld hat dabei die Abmessungen 2x2. Die y-Achse spielt in diesem Spiel keine Rolle, alle y-Werte werden gleich 0 sein.

Neben den Spielfeld-Dimensionen müssen wir uns auch Gedanken über die Größen der einzelnen Spielelemente machen. Der Einfachheit halber, werden wir deren Radius auf 0.5 und ihren Durchmesser damit auf 1 festlegen. Dies entspricht weitestgehend dem Original, in dem Invaders und Schiff ungefähr gleich groß sind. Auch die initiale Positionierung der Invader und Blöcke müssen wir uns überlegen. Im Original stehen die Invader am oberen Ende des Spielfeldes mittig. Zwischen den Invadern ist immer ein kleiner Freiraum. Wir werden das nachbilden. Aus Performancegründen müssen wir die Anzahl der Invader etwas herunterschrauben. Anstatt fünf Reihen zu je elf Invadern, werden wir vier Reihen zu je acht Invadern haben. Die Invader haben einen Durchmesser von 1, also werden wir sie im Abstand von zwei Einheiten neben und untereinander positionieren. Die Invader der obersten Reihe haben also z-Werte von -15, die der nächsten Reihe -13 usw. Der Invader ganz links einer Reihe sitzt auf x=-7, der nächste auf -5 usw. Das Ganze sieht zu Beginn des Spiels so aus:

Datei:invaderplacement.png

Auch bei den Blöcken werden wir ein wenig vom Original abweichen. In diesem Bestand ein Block aus mehreren Subblöcken. Diesen Umstand werden wir übernehmen. Aus Performancegründen werden wir die Subblöcke aber vereinfachen. Ein Block bildet aus fünf Subblöcken eine U-Form. Jeder Subblock hat dabei die Größe 1x1. Anstatt vier Blöcke, wie im Original, werden wir nur drei Blöcke haben. Diese verteilen wir gleichmäßig über das Spielfeld. Das Zentrum des ersten Blocks befindet sich dabei an Position x=-10,z=-2.5, der nächste an x=0,z=-2.5 und der dritte an x=10,z=-2.5. Die jeweils fünf Subblöcke bauen wir um die Zentren. Das Ganze sieht dann wie folgt aus:

Datei:blockplacement.png

Das Schiff wird sich an der unteren gelben Linie des Spielfelds nach links und rechts bewegen können, die Invader werden von oben nach unten fliegen und dabei immer an der linken bzw. rechten Spielfeldlinie in die andere Richtung steuern beginnen. Damit haben wir unser Spielfeld definiert und können uns jetzt der Simulation widmen.

Die Simulation

Kernstück jedes Spiels, ist die Simulation der Spielewelt selbst. Diese sollte so unabhängig, wie möglich von allen anderen Modulen sein, wie zum Beispiel dem Grafikmodul. Ziel ist es, die oben genannten Aspekte des Spiels zu simulieren, d.h. die Invader, das Schiff, die Blöcke, Schüsse und Explosionen. Wir werden jedes dieser Elemente in einer eigenen Klasse abbilden. Als große Klammer erstellen wir auch noch eine Simulationsklasse, die all die Elemente beherbergt und für den eigentlichen Spielablauf sorgt. Das bedeutet, dass die Simulationsklasse dafür sorgt, dass sich die Invader bewegen, Schüsse ihr Ziel treffen und so weiter. Die Simulation wird dabei im Koordinaten-System ablaufen, das im letzten Kapitel beschrieben wurde, also in der x-y-z-Ebene. Wir werden hier jede Klasse der Simulation im Detail besprechen. Die Simulation werden wir später dann in unserem Main Loop verwenden und ausführen. Hier ergibt sich ein Henne-Ei Problem: was beschreib ich zuerst. Meiner Meinung nach ist die Struktur des Programms zu diesem Zeitpunkt unerheblich, das Verstehen der Simulation, ist erstmal der wichtigste Aspekt. Darum stürzen wir uns gleich einmal auf die Klassen der Simulation.

Randnotizen: Wir brauchen eine Klasse, die es uns erlaubt, vektoriell zu arbeiten. Zu diesem Zweck hab ich eine äußerst simple Vektor-Klasse implementiert. Diese besitzt Methoden, die sich mit den mathematischen Ausdrücken im Mathematik Kapitel decken. Die Klasse könnt ihr euch vorab unter [http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/spaceinvaders/simulation/Vector.java http://code.google.com/p/android-gamedev/source/browse/trunk/src/com/badlogic/gamedev/spaceinvaders/simulation/Vector.java] anschauen. Am wichtigsten dabei wird für uns die Methode zum Messen der Distanz zwischen zwei Punkten (hier fälschlich auch Vektoren genannt) sein. Des Weiteren werden wir von herkömmlichen Best Practices der Softwareentwicklung ein wenig abweichen. Anstatt Getter und Setter Methoden für jede Klasse zu erstellen, machen wir sämtliche Attribute public. Alle Methoden unserer Klassen haben einen rein funktionalen Charakter und kapseln Arbeitsgänge. Hintergrund dafür, ist der Performancezuwachs, den wir dadurch gewinnen. Die Dalvik Virtual Machine ist zwar bereits relativ gut, Methodenaufrufe kosten aber doch Zeit, die wir, speziell in Spielen, nicht haben. Getter- und Setter-Methoden sind in diesem Rahmen meist Overkill und sollten vermieden werden. In unserem simplen Space Invaders-Klon mag dies noch keine große Auswirkung auf die Performance haben, gestaltet man aber komplexere Spiele, kann es zu Laufzeiteinbußen kommen. Wir sind uns also des schlechten Stils vollkommen bewusst, nehmen ihn aber aus Gründen der Performance in Kauf.

Fangen wir mit der Klasse für Blöcke an.

Block-Klasse

Beginnen wir gleich mit ein wenig Code. Hier die Block-Klasse:

public class Block 
{	
   public final static float BLOCK_RADIUS = 0.5f; 	
   public Vector position = new Vector( );
   
   public Block(Vector position) 
   {
      this.position.set( position );
   }
}

Eine Instanz dieser Klasse beschreibt einen der (Sub-)Blöcke, wie im Spielfeld-Kapitel beschrieben. Dieser besitzt eine Position, die wir als Attribut speichern (position). Zusätzlich haben wir eine statisches und finales Attribut, das den Radius eines Blocks definiert, in diesem Fall 0.5f Einheiten. Diese Art von Konstanten wird uns in den Simulationsklassen öfter begegnen. Sie legen die oben definierten Maße und Einheiten fest, die wir zum Ausführen der Simulation benötigen. Der Konstruktor ist keine Zauberei, es wird lediglich die Position des Blocks gesetzt. Mehr macht die Klasse nicht. Die Blöcke bewegen sich nicht, daher bedarf es keines Updates der Position. Auch Schüsse gibt der Block keine ab. Die Klasse dient lediglich zum Speichern der Position eines Blockes. Auf zur nächsten Klasse.

Explosion-Klasse

Mit der Explosion-Klasse modellieren wir Explosionen... Eine Explosion besitzt eine Position im Raum, sowie eine Lebenszeit. Diese misst, wie lange die Explosion bereits gedauert hat. Nach einer bestimmten Zeitspanne soll die Explosion schließlich wieder verschwinden. Hier begegnet uns der zweite Mechanismus, den man in der Regel neben Konstanten, wie dem Radius, in Simulationsklassen findet: die update Methode. In jeder Iteration des Main Loop stoßen wir die Simulation selbst mit dem Aufruf ihrer update Methode an. Der Methode übergibt man die Zeitspanne, die man simulieren möchte, normalerweise die Delta Time, die wir ja schon brav in der GameActivity messen. Die Simulation wiederum ruft von jedem Element dessen update Methode auf. Die Elemente sorgen dann dafür, dass sie ihren Status entsprechend der vergangenen Zeit anpassen. Das kann das zeitbasierte ändern von Positionen sein, die Reaktion auf Spielereignisse, wie Schüsse, und so weiter. Im Fall unserer Explosion ändert sich nur deren Lebensdauer. Diese erhöhen wir einfach bei jedem Aufruf um die übergebene Delta Time. Hier der Code zur Klasse:

public class Explosion 
{
   public static final float EXPLOSION_LIVE_TIME = 1;
   public float aliveTime = 0;
   public final Vector position = new Vector( );

   public Explosion( Vector position )
   {
      this.position.set( position );
   }
	
   public void update( float delta )
   {
      aliveTime += delta;
   }	
}

Keine Überraschungen. Die maximale Lebensdauer einer Explosion definieren wir wieder über die Konstante der Klasse und setzen diese auf 1, für eine Sekunde. Außerdem besitzt jede Instanz der Klasse einen Member zur Speicherung ihrer bereits abgelaufenen Lebensdauer, sowie ihrer Position. Zweitere wird einmal im Konstruktor gesetzt. Zum Konstruktor gesellt sich eine weitere Methode, die bereits besprochene update-Methode. Diese bekommt die Delta Time übergeben, die sie auf das Attribut Lebensdauer aufaddiert. Dieses Attribut werden wir später in der Simulation dazu verwenden, zu prüfen, ob die Explosion beendet ist oder nicht. Auch diese Klasse ist wieder sehr einfach, wenden wir uns also einer etwas komplizierteren Klasse zu.

Shot-Klasse

Wie der Name besagt, simuliert diese Klasse einen Schuss in unserem Spiel. Ein Schuss definiert sich wieder über eine Position im Raum. Außerdem bewegt sich ein Schuss, d.h. wir brauchen wieder eine update-Methode, die die Position des Schusses, in Abhängigkeit von der vergangenen Zeit (Delta Time), ändert. Der Schuss muss auch eine Richtung besitzen, in die er fliegt. Diese ist abhängig davon, ob er vom Schiff oder von einem Invader stammt. Im ersten Fall bewegt sich der Schuss immer weiter in den negativen Bereich (z wird kleiner), im zweiten Fall bewegt sich der Schuss in den positiven Bereich (z wird größer). Auch wollen wir, dass der Schuss weiß ob er das Spielfeld verlassen hat. All dies implementieren wir wie folgt:

public class Shot 
{
   public static float SHOT_VELOCITY = 10;
   public final Vector position = new Vector();
   public boolean isInvaderShot;
   public boolean hasLeftField = false;

   public Shot( Vector position, boolean isInvaderShot )
   {
      this.position.set( position );
      this.isInvaderShot = isInvaderShot;
   }

   public void update(float delta) 
   {	
      if( isInvaderShot )
         position.z += SHOT_VELOCITY * delta;
      else
         position.z -= SHOT_VELOCITY * delta;
	
      if( position.z > Simulation.PLAYFIELD_MAX_Z )
         hasLeftField = true;
      if( position.z < Simulation.PLAYFIELD_MIN_Z )
         hasLeftField = true;
   }
}
   

Gehen wir zuerst die Attribute durch. Die Konstante SHOT_VELOCITY definiert die Geschwindigkeit. Und zwar in Einheiten pro Sekunde, mit denen ein Schuss fliegt. Übersetzt bedeutet der Wert: in einer Sekunde fliegt der Schuss zehn Einheiten weit. Außerdem besitzt die Klasse ein Attribut für die Position, eine Markierung, ob der Schuss vom Schiff stammt oder von einem Invader, sowie die Information, ob der Schuss das Spielfeld verlassen hat. Der Konstruktor bietet wieder keine Überraschungen und setzt lediglich zwei Attribute. Schauen wir uns also die update-Methode genauer an.

Als erstes ändern wir die Position des Schusses. Schüsse fliegen immer entlang der z-Achse, daher müssen wir auch nicht diese Koordinate der Position ändern. Die Änderungen erfolgt über das subtrahieren/addieren der Geschwindigkeit multipliziert mit der vergangenen Zeit. Die Geschwindigkeit haben wir als Konstante definiert (SHOT_VELOCITY) die Zeit bekommen wir als Parameter, sie entsprich der Delta Time. Ein Schuss bewegt sich also pro Frame um SHOT_VELOCITY * Delta Time entlang der z-Achse. Die Richtung ist abhängig vom Typen des Schusses, also, ob er von einem Invader kommt oder vom Schiff.

In der update-Methode prüfen wir auch, ob der Schuss das Spielfeld verlassen hat. Nachdem er sich nur entlang der z-Achse bewegt und wir davon ausgehen können, dass er von einem Schiff/Invader abgefeuert wurde und somit gültige x/y-Koordinaten hat, prüfen wir auch nur, ob der das Spielfeld in der z-Achse verlassen hat. Dazu bietet die Klasse Simulation zwei statische Attribute, die die maximale und minimale z-Koordinate des Spielfeldes angeben. Ergibt die Prüfung, dass der Schuss nicht mehr im Spielfeld ist, setzen wir das Attribut hasLeftField auf true. Die Simulation wird diesen Wert später dazu verwenden, um zu bewerten, ob der Schuss aus der Simulation entfernt werden kann oder nicht. Es verhält sich hier also genauso, wie bei der Lebensdauer der Explosion

Das wichtigste an dieser Klasse, ist das zeitbasierte aktualisieren der Position. Dieses Prinzip müsst ihr verinnerlichen, es wird uns noch ein paar Mal begegnen.

Ship-Klasse

Und das Muster setzt sich fort. Auch unser Schiff braucht natürlich eine Position. Ebenso wie in der Klasse Blöcke, hat es auch einen Radius und eine Höchstgeschwindigkeit. Zusätzlich kann sich ein Schiff auch in Luft auflösen, sprich explodieren. Dies müssen wir irgendwie vermerken, da das Schiff in dieser Zeit nicht beschossen werden kann. Außerdem hat ein Schiff eine Anzahl an Leben (drei als Standard). Schauen wir uns an, wie wir das implementieren.

public class Ship 
{
   public static final float SHIP_RADIUS = 1;
   public static final float SHIP_VELOCITY = 20;
   public final Vector position = new Vector( );
   public int lives = 3;
   public boolean isExploding = false;
   public float explodeTime = 0;
	
   public void update( float delta )
   {
      if( isExploding )
      {
         explodeTime += delta;
         if( explodeTime > Explosion.EXPLOSION_LIVE_TIME )
         {
            isExploding = false;
            explodeTime = 0;
         }
      }
   }
}

Wieder begegnen uns zwei Konstanten, der Schiffradius sowie die maximale Schiffgeschwindigkeit. Die Position merken wir uns in Form eines Vektors. Ein weiteres Attribut hält fest, wie viele Leben das Schiff noch besitzt. Das Boolean isExploding speichert, ob das Schiff gerade explodiert, das Attribut explodeTime vermerkt, wie lange die Explosion schon dauert, analog zur aliveTime der Explosion. Konstruktor haben wir keinen, da die Position bereits auf (0,0,0) initialisiert wird (Konstruktor des Vektors). Schauen wir uns die update-Methode an.

Wieder bekommen wir als Parameter die Delta Time. Explodiert das Schiff, rechnen wir diese einfach auf das Attribut explodeTime auf. Ist das Schiff lange genug explodiert, setzen wir isExploding, sowie das Attribut explodeTime wieder zurück. Das Schiff befindet sich danach wieder im normalen Zustand.

Aufmerksame Leser werden bemerken, dass das Schiff nicht bewegt wird. Das machen wir dann später in der Simulation auf Basis der Benutzereingabe. Auch das Abziehen von Leben, im Fall einer Kollision mit einem Schuss oder Invader, wird in der Simulation erledigt.

Invader-Klasse

Jetzt kommt die erste etwas komplexere Simulationsklasse. Der Invader hat wie alles andere natürlich erstmal eine Position. Auch Konstanten für Radius und Geschwindigkeit gibt es wieder. Das schwierige am Invader ist sein komplexes Bewegungsmuster. Rekapitulieren wir diese schnell: zu Beginn bewegt sich ein Invader nach links. Nach einer bestimmten Distanz bewegt er sich um eine Einheit nach unten (positiv z), um danach nach rechts einzuschlagen. Nach einer bestimmten Strecke nach rechts, fährt er wieder nach unten und dann nach links, das ganze wiederholt sich. Der Invader hat also drei Zustände: fahr nach links, nach unten und nach rechts. In Abhängigkeit des Zustands verändert er seine Position entlang der x- bzw. z-Achse. Die Distanzen, die ein Invader nach links und rechts zurücklegen muss, damit er in den nächsten Zustand wechseln kann, können wir schön aus der Beschreibung des Spielfeldes ablesen. Schauen wir uns noch mal das Bild dazu an:

Datei:invadermovement.png

Anfangs legt der Invader sechs Einheiten nach links zurück. Danach eine Einheit nach unten, dann 13 Einheiten nach rechts, eine Einheit nach unten, 13 Einheiten nach links ad infinitum. Dies zu implementieren scheint anfänglich etwas komplex, ist aber bei genauerer Betrachtung relativ einfach. Wir merken uns für den aktuellen Status (links, rechts, runter), wie weit der Invader schon gewandert ist. Hat er die maximale Distanz für den Zustand erreicht (13 oder 1), wechseln wir in den nächsten Zustand. Den Zustand selbst, müssen wir uns natürlich auch merken.

Die Bewegung des Invaders erfolgt natürlich wieder zeitbasiert über die Multiplikation der Geschwindigkeit mit der Delta Time. Das Ergebnis rechnen wir dann entsprechend dem Status auf die Position auf, entweder auf die x-Koordinate (links, rechts) oder die z-Koordinate (runter). Hier der gesamte Code:

public class Invader 
{	
   public static float INVADER_RADIUS = 0.75f;
   public static float INVADER_VELOCITY = 1;
   public static int INVADER_POINTS = 40;
   public final static int STATE_MOVE_LEFT = 0;
   public final static int STATE_MOVE_DOWN = 1;
   public final static int STATE_MOVE_RIGHT = 2;
	
   public final Vector position = new Vector();
   public int state = STATE_MOVE_LEFT;
   public boolean wasLastStateLeft = true;
   public float movedDistance = Simulation.PLAYFIELD_MAX_X / 2;	
		
   public Invader( Vector position )
   {
      this.position.set( position );
   } 	
		
   public void update(float delta, float speedMultiplier) 
   {			
      movedDistance += delta * INVADER_VELOCITY * speedMultiplier;
      if( state == STATE_MOVE_LEFT )
      {
         position.x -= delta * INVADER_VELOCITY * speedMultiplier;
         if( movedDistance > Simulation.PLAYFIELD_MAX_X )
         {
            state = STATE_MOVE_DOWN;
            movedDistance = 0;
            wasLastStateLeft = true;
         }
      }
      if( state == STATE_MOVE_RIGHT )
      { 
         position.x += delta * INVADER_VELOCITY * speedMultiplier;
         if( movedDistance > Simulation.PLAYFIELD_MAX_X )
         {
            state = STATE_MOVE_DOWN;
            movedDistance = 0;
            wasLastStateLeft = false;
         }
      }
      if( state == STATE_MOVE_DOWN )
      {
         position.z += delta * INVADER_VELOCITY * speedMultiplier;
         if( movedDistance > 1 )
         {
            if( wasLastStateLeft )
               state = STATE_MOVE_RIGHT;
            else
               state = STATE_MOVE_LEFT;
            movedDistance = 0;
         }
      }		
   }
}

Die Definition der Konstanten für den Radius und die Geschwindigkeit sollten keine große Überraschung sein. Die nächsten drei Konstanten stehen für die drei Status, in der sich ein Invader befinden kann. Natürlich haben wir auch wieder ein Attribut für die Position. Ein weiteres Attribut hält den aktuellen Status des Invaders, den setzen wir zu Beginn auf STATE_MOVE_LEFT. Aus organisatorischen Gründen merken wir uns auch, ob der letzte Zustand nach links oder nach rechts geführt hat. Das letzte Attribut speichert die gefahrene Distanz für den aktuellen Zustand, den initialisieren wir auf die Hälfte des maximalen x-Wertes der Simulation (siehe Grafik). Den Konstruktor kennen wir in der Form auch schon. Schauen wir uns also die update-Methode an.

Was als erstes auffällt, ist ein zweiter Parameter namens speedMultiplier. Unser Klon soll ja auf Dauer etwas fordernder werden. Dazu lassen wir einfach die Invader schneller werden. Nach jeder Welle erhöhen wir diesen Speedmultiplier etwas, was wiederum Auswirkungen auf die Geschwindigkeit der Invader hat. Dazu mehr in der Beschreibung der Simulation-Klasse. Wir merken uns nur, dass wir diesen Wert bei der Zeitbasierten Bewegung aufmultiplizieren müssen.

Als erstes addieren wir in dieser Methode die im letzten Frame zurückgelegt Strecke auf movedDistance. Keine große Sache, wir berechnen einfach den zeitbasierten Weg (mal Speedmultiplier). Als nächstes prüfen wir, in welchem Zustand wir uns befinden.

Sind wir im Zustand STATE_MOVE_LEFT, bewegen wir unseren Invader zeitbasiert ein Stück nach links (Geschwindigkeit * Delta Time * Speedmultiplier = Im Frame zurückgelegte Strecke). Danach wird kontrolliert, ob wir die maximale Distanz für diesen Zustand zurückgelegt haben. Ist dies der Fall, wechseln wir den Status in STATE_MOVE_DOWN und setzen movedDistance auf 0. Auch merken wir uns, dass der letzte horizontale Status nach links ging.

Dasselbe Spiel spielen wir, wenn wir uns im Zustand STATE_MOVE_RIGHT befinden. Anstatt nach links, bewegen wir uns nach rechts. Sollte die maximale Distanz für den Zustand überschritten sein, setzen wir den Status wieder auf STATE_MOVE_DOWN, movedDistance auf 0 und merken uns, dass wir nach rechts gefahren sind.

Die Handhabung des Zustands STATE_MOVE_DOWN läuft ein wenig anders ab. Zuerst bewegen wir uns einmal zeitbasiert nach unten. Haben wir die maximale Distanz für den Status überschritten (>1), wechseln wir in einen der horizontalen Zustände. Fuhren wir zuvor nach links, müssen wir jetzt nach rechts fahren und umgedreht. Natürlich setzen wir auch movedDistance wieder zurück.

Pfuh, ganz schönes Stück Arbeit, so ein Invader. Damit haben wir aber die letzte Klasse fertig besprochen und können uns der eigentlichen Simulationsklasse widmen.

Simulation-Klasse

Die Aufgabe der Klasse, ist das Zusammenspiel der verschiedenen Spielelemente zu regeln. Zum einen sorgt die Klasse dafür, dass alle Elemente, die eine update-Methode besitzen, auch aktualisiert werden. Zum anderen prüft sie verschiedene Ereignisse, wie die Kollision von Schüssen und führt entsprechende Reaktionen aus. Beispielsweise das verschwinden lassen von Invadern, das Erzeugen von Explosionen und so weiter. In unserem Fall bietet die Klasse auch drei Methoden, die von außen angesteuert werden. Diese sind für die Bewegung des Schiffes sowie das Feuern eines Schusses verantwortlich. Wir werden die Klasse in kleinen Stückchen sezieren, um ihre Funktionsweise zu verstehen. Beginnen wir mit den Attributen:

public class Simulation 
{		
   public final static float PLAYFIELD_MIN_X = -14;
   public final static float PLAYFIELD_MAX_X = 14;
   public final static float PLAYFIELD_MIN_Z = -15;
   public final static float PLAYFIELD_MAX_Z = 2;
	
   public ArrayList<Invader> invaders = new ArrayList<Invader>();
   public ArrayList<Block> blocks = new ArrayList<Block>( );
   public ArrayList<Shot> shots = new ArrayList<Shot>( );
   public ArrayList<Explosion> explosions = new ArrayList<Explosion>( );

   public Ship ship;
   public Shot shipShot = null;

   public SimulationListener listener;
    
   public float multiplier = 1;
   public int score;
   public int wave = 1;
   
   private ArrayList<Shot> removedShots = new ArrayList<Shot>();
   private ArrayList<Explosion> removedExplosions = new ArrayList<Explosion>( );

... to be continued ...

Die ersten vier Attribute sind wieder Konstanten für unser Spielfeld. Die ersten beiden geben die Begrenzungen auf der x-Achse an, die anderen beiden die Begrenzungen auf der z-Achse.

Es folgen die Listen für die verschiedenen Spielelemente. Wieder keine große Überraschung, wir verwenden einfach eine ArrayLists für Invader, Blöcke, Schüsse und Explosionen.

Auch das Schiff müssen wir speichern, genauso wie den aktuellen Schuss des Schiffs. Dieser kommt auch in die shots-Liste, wird aber separat noch mal gespeichert, damit wir wissen, ob es ein Schiffschuss gibt oder nicht.

Die Simulation erlaubt auch das Einhängen eines Listeners. Der Sinn dahinter: Außenstehende Klassen bekommen so essentielle Ereignisse in der Simulation mit, wie Explosionen oder das Abfeuern von Schüssen. Wir werden später einen solchen Listener einhängen, der die entsprechenden Soundeffekte, für bestimmte Ereignisse, abspielt.

Der Multiplikator ist uns im Invader schon begegnet, als Parameter für die update-Methode. Zu Beginn der Simulation setzen wir diesen auf 1. Dies bedeutet, dass Invader mit der Geschwindigkeit Invader.INVADER_VELOCITY fliegen werden. Später werden wir diesen Multiplier erhöhen, damit die Invader schneller werden.

Natürlich speichern wir auch den aktuellen Punktestand, ohne diesen wäre das Spiel nur halb so lustig. Des Weiteren speichern wir noch, wie viele Wellen an Invadern bereits aufgetreten sind. Reine Statistik, ohne große Funktion.

Die letzten beiden ArrayLists sind Utility-Attribute, die wir zum Löschen von Schüssen und Explosionen benötigen. Wir wollen diese nicht immer neu instanzieren, da sonst der Garbage Collector permanent anspringt.

Instanzieren wir eine Simulation, so wollen wir ein fix und fertiges Spielfeld darin vorfinden. Dort sollen alle Elemente so positioniert sein, wie im Abschnitt Spielfeld dargelegt. Das Befüllen der Simulation werden wir in eine eigene Methode namens populate packen. Schauen wir uns Konstruktor und populate an:

... continued ...
public Simulation( )
{
   populate( );
}
 
private void populate( )
{
   ship = new Ship();    

   for( int row = 0; row < 4; row++ )
   {
      for( int column = 0; column < 8; column++ )
      {
         Invader invader = new Invader( new Vector( -PLAYFIELD_MAX_X / 2 + column * 2f, 0, PLAYFIELD_MIN_Z + row * 2f ));				
         invaders.add( invader );
      }
   }
		
   for( int shield = 0; shield < 3; shield++ )
   {
      blocks.add( new Block( new Vector( -10 + shield * 10 -1, 0, -2) ) );
      blocks.add( new Block( new Vector( -10 + shield * 10 -1, 0, -3) ) );
      blocks.add( new Block( new Vector( -10 + shield * 10 + 0, 0, -3) ) );
      blocks.add( new Block( new Vector( -10 + shield * 10 + 1, 0, -3) ) );
      blocks.add( new Block( new Vector( -10 + shield * 10 + 1, 0, -2 ) ) );
   }
}
... to be continued ...

Im Konstruktor rufen wir lediglich populate auf, das übernimmt das Befüllen der Simulation. In der Methode populate beginnen wir damit, das Schiff zu instanzieren. Als nächstes platzieren wir die Invader auf dem Spielfeld, wie vorher schon beschrieben. Die erste Schleife geht dabei über die vier Reihen, die nächste über die jeweils acht Invader pro Reihe. Die neu erstellten Invader geben wir in unser Attribut invaders, damit wir sie auch später noch verfügbar haben. Danach erstellen wir die 3 * 5 Blöcke, ebenfalls so positioniert, wie im Abschnitt Spielfeld beschrieben. Als Übung könnt ihr euch ja die Positionen, so wie sie sich aus dem Code ergeben, errechnen und mit der Beschreibung im Spielfeldabschnitt vergleichen.

Die Hauptarbeit der Simulation, ist das zeitbasierte aktualisieren aller Spielelemente. Dazu haben wir, wie gehabt, eine update-Methode. Von außen wird dieser die Delta Time mitgeteilt. Sehen wir uns die Methode an:

... continued ...
public void update( float delta )
{			
   ship.update( delta );
   updateInvaders( delta );
   updateShots( delta );
   updateExplosions(delta);
   checkShipCollision( );
   checkInvaderCollision( );
   checkBlockCollision( );
   checkNextLevel( );		
}
... to be continued ...

Schön aufgeräumt, mit Methodenaufrufen, gehen wir die einzelnen Elemente durch. Als erstes aktualisieren wir das Schiff. Wir erinnern uns daran, dass beim Update, im Fall einer Explosion, deren Zeit gemessen wird und ein entsprechender Vermerk gesetzt wird. Anschließend bringen wir die Invader, die Schüsse und die Explosionen auf den augenblicklichen Stand. Wir sehen uns die drei Methoden gleich im Detail an. Als nächstes schauen wir, ob es Kollisionen zwischen dem Schiff und Schüssen bzw. Invadern gab (checkShipCollision). Dasselbe tun wir dann auch für die Invader (checkInvaderCollision) und für die Blöcke (checkBlockCollision). Zum Schluss prüfen wir, ob alle Invader der aktuellen Welle zerstört wurden. Ist dies der Fall, befüllen wir das Spielfeld mit neuen Invadern (checkNextLevel) und damit mit der nächsten Welle.

Das ist die große Klammer, die in jeder Iteration einen Schritt in der Simulation ausführt. Wie wollen uns jetzt mit den in update aufgerufenen Methoden auseinandersetzen. Gehen wir sie der Reihe nach durch:

... continued ...
private void updateInvaders( float delta )
{
   for( int i = 0; i < invaders.size(); i++ )
   {
      Invader invader = invaders.get(i);
      invader.update( delta, multiplier );
   }
}
... to be continued ...

Wie gehen einfach durch alle Invader durch und rufen deren update-Methode auf, mit der aktuellen Delta Time, sowie dem Wert des Multiplikators (der die Geschwindigkeit je nach Nummer der aktuellen Welle etwas erhöht). Wir verwenden keine Iteratoren in der Schleife, da diese unter Android bzw. Dalvik Objekte instanzieren, die wiederum den Garbage Collector anwerfen würden. Wie bereits erwähnt, sollten wir das vermeiden.

Als nächstes schauen wir uns updateShots an:

... continued ...
private void updateShots( float delta )
{
   removedShots.clear();
   for( int i = 0; i < shots.size(); i++ )
   {
      Shot shot = shots.get(i);
      shot.update(delta);
      if( shot.hasLeftField )
         removedShots.add(shot);
   }
	
   for( int i = 0; i < removedShots.size(); i++ )		
      shots.remove( removedShots.get(i) );
		
   if( shipShot != null && shipShot.hasLeftField )    
      shipShot = null;

   if( Math.random() < 0.01 * multiplier && invaders.size() > 0 )
   {			
      int index = (int)(Math.random() * (invaders.size() - 1));
      Shot shot = new Shot( invaders.get(index).position, true );			
      shots.add( shot );
      if( listener != null )
         listener.shot();
   }
   
}
... to be continued ...

Hier passiert schon ein wenig mehr. Zuerst rufen wir die update-Methode von jedem Schuss auf. Wir erinnern uns, diese prüft, ob der Schuss außerhalb des Spielfelds ist und bewegt den Schuss. In der Schleife schauen wir nach dem Update, ob der Schuss das Spielfeld verlassen hat, indem wir das entsprechende Attribut prüfen, und geben ihn in diesem Fall in die Liste der zu löschenden Schüsse.

Die nächste Schleife löscht alle Schüsse, die wir gerade in die Liste removedShots gegeben haben aus der Liste shots. Damit verschwinden sie komplett aus dem Spiel. Da wir nicht über Iteratoren arbeiten, müssen wir dies, etwas umständlich, mit der removedShots Liste machen.

Als nächstes schauen wir, ob auch ein Schuss vom Schiff auf dem Spielfeld ist und ob dieser das Spielfeld verlassen hat. Ist dies der Fall, setzen wir shipShot auf null, womit wir später wissen, dass kein Schuss des Schiffes mehr am Spielfeld ist. Das entfernen des Schusses passierte schon zuvor in der Schleife, da der Schiffschuss ja auch in der shots-Liste ist.

Der nächste Teil ist ein wenig schwerer zu verstehen. Die Invader sollen ja ebenfalls hin und wieder schießen. Dieses „hin und wieder“ ist zufallsbasiert. Dazu holen wir uns über Math.random() eine Zahl zwischen 0 und 1. Ist die Zahl kleiner als 0.01 * Faktor und gibt es mehr als einen Invader, erzeugen wir einen neuen Schuss. D.h. die Wahrscheinlichkeit, dass in einem Simulationsupdate ein Schuss von einem Invader abgegeben wird, beträgt ein Prozent.

Im Körper der if-Abfrage, suchen wir uns dann ebenfalls zufallsbasiert einen der Invader aus und erzeugen an dessen Position einen neuen Schuss, den wir in die shots-Liste einfügen. Außerdem rufen wir hier zum ersten Mal einen eventuellen Listener auf, der so weiß, dass ein Schuss abgegeben wurde. Damit hätten wir schon mal einen essentiellen Mechanismus, nämlich das Schießen der Invader abgehakt. Die Schüsse, die wir hier neu hinzufügen, werden im nächsten Update über die vorhergehende Schleife wieder verarbeitet.

Als nächstes schauen wir uns an, was wir mit den Explosionen machen:

... continued ...
public void updateExplosions( float delta )
{
   removedExplosions.clear();
   for( int i = 0; i < explosions.size(); i++ )
   {
      Explosion explosion = explosions.get(i);
      explosion.update( delta );
      if( explosion.aliveTime > Explosion.EXPLOSION_LIVE_TIME )
         removedExplosions.add( explosion );
   }

   for( int i = 0; i < removedExplosions.size(); i++ )
      explosions.remove( explosions.get(i) );
}  
... to be continued ...

Wie schon bei den Invadern, gehen wir über alle Explosionen am Spielfeld und aktualisieren sie. Waren sie lange genug am Leben, geben wir sie in die removedExplosion-Liste, über die wir sie in der nächsten Schleife dann endgültig vom Spielfeld entfernen.

Damit haben wir alle Update-Methoden abgehandelt. Es verbleiben noch die Kollisions-Routinen. Starten wir mit checkInvaderCollision:

... continued ...
private void checkInvaderCollision() 
{		
   if( shipShot == null )
      return;							
			
   for( int j = 0; j < invaders.size(); j++ )
   {
      Invader invader = invaders.get(j);
      if( invader.position.distance(shipShot.position) < Invader.INVADER_RADIUS )
      {									
         shots.remove( shipShot );
         shipShot = null;
         invaders.remove(invader);
         explosions.add( new Explosion( invader.position ) );
         if( listener != null )
            listener.explosion();
         score += Invader.INVADER_POINTS;
         break;
      }
   }			
}
... to be continued ...

Unsere Aufgabe hier, ist es, einen eventuell vorhandenen Schuss des Schiffes mit allen Invadern zu prüfen. Befindet sich der Schuss innerhalb des Radius eines Invaders, so geht dieser in einer Explosion hoch.

Als erstes schauen wir, ob es überhaupt einen Schiffschuss gibt. Ist dies nicht der Fall, verabschieden wir uns gleich wieder. Danach gehen wir jeden Invader durch. Ist die Distanz zwischen Invader und Schiffschuss kleiner als der Radius eines Invader, löschen wir den Schiffschuss aus shots und setzen shipShot auf null. Danach löschen wir auch den getroffenen Invader und erzeugen eine neue Explosion an der Position des toten Invaders. Ist ein Listener gesetzt, rufen wir diesen auf und teilen ihm mit, dass eine Explosion stattgefunden hat. Zu guter Letzt erhöhen wir den Punktestand und brechen die Schleife ab.

Es sei hier angemerkt, dass wir einen kleinen Shortcut genommen haben. Nachdem wir die Invader von hinten nach vorne in die invaders-Liste eingefügt haben, kommen wir mit dieser Methode aus. Wären die Invader nicht nach Tiefe sortiert, müssten wir die Distanz zu jedem Invader prüfen und uns den merken, der am nächsten getroffenen ist. Das bleibt uns aber erspart, da wir ja schlau sind.

Das Schiff müssen wir sowohl auf Kollisionen mit Schüssen, wie auch mit Invadern selbst prüfen:

... continued ...
private void checkShipCollision() 
{	
   removedShots.clear();
	
   if( !ship.isExploding )
   {
      for( int i = 0; i < shots.size(); i++ )
      {
         Shot shot = shots.get(i);
         if( !shot.isInvaderShot )
            continue;											
		
         if( ship.position.distance(shot.position) < Ship.SHIP_RADIUS )
         {					
            removedShots.add( shot );
            shot.hasLeftField = true;
            ship.lives--;
            ship.isExploding = true;
            explosions.add( new Explosion( ship.position ) );
            if( listener != null )
                listener.explosion();
            break;
         }			
      }
	
      for( int i = 0; i < removedShots.size(); i++ )		
         shots.remove( removedShots.get(i) );
   }
		 
   for( int i = 0; i < invaders.size(); i++ )
   {
      Invader invader = invaders.get(i);
      if( invader.position.distance(ship.position) < Ship.SHIP_RADIUS )
      {
         ship.lives--;
         invaders.remove(invader);
         ship.isExploding = true;
         explosions.add( new Explosion( invader.position ) );
         explosions.add( new Explosion( ship.position ) );
         if( listener != null )
            listener.explosion();
         break;
      }
   }
}  
... to be continued ...

Zuerst prüfen wir, ob ein Invaderschuss das Schiff getroffen hat. Das Ganze tun wir aber nur, wenn das Schiff nicht explodiert. Wurde das Schiff getroffen, löschen wir den Schuss, vermindern die Leben des Schiffes um eins und erzeugen eine Explosion. Außerdem sagen wir dem Schiff, dass es explodieren soll. Dies machen wir, indem wir das entsprechende Attribut setzen. Der Listener wird aufgerufen und die Schleife abgebrochen. Mehr als ein Mal kann das Schiff nicht getroffen werden.

Als nächstes prüfen wir, ob ein Invader mit dem Schiff kollidiert ist. Hier machen wir das Gleiche, wie bei den Shots, Distanz kontrollieren, Leben abziehen, Explosionen erzeugen (für Invader und Schiff) und Invader löschen. Zum Schluss rufen wir wieder den Listener auf und brechen die Schleife ab. Eigentlich alles nicht so schlimm.

Jetzt müssen wir noch die Blöcke auf Kollisionen mit Schüssen überprüfen:

... continued ...
private void checkBlockCollision( )
{
   removedShots.clear();
	
   for( int i = 0; i < shots.size(); i++ )
   {
      Shot shot = shots.get(i);			
							
      for( int j = 0; j < blocks.size(); j++ )
      {
         Block block = blocks.get(j);
         if( block.position.distance(shot.position) < Block.BLOCK_RADIUS )
         {					
            removedShots.add( shot );
            shot.hasLeftField = true;
            blocks.remove(block);
            break;
         }
      }			
   }
	
   for( int i = 0; i < removedShots.size(); i++ )		
      shots.remove( removedShots.get(i) );
}
... to be continued ...

Selbes Spiel wie immer. Wir prüfen jeden Schuss mit jedem Block. Wurde ein Block getroffen, löschen wir sowohl Block, als auch Schuss. Explosionen gibt es in diesem Fall keine.

Schauen wir uns an, wie wir die nächste Welle lostreten:

... continued ...
private void checkNextLevel( )
{
   if( invaders.size() == 0 && ship.lives > 0 )
   {
      blocks.clear();
      shots.clear();
      shipShot = null;
      Vector shipPosition = ship.position;
      int lives = ship.lives;
      populate();
      ship.lives = lives;
      ship.position.set(shipPosition);
      multiplier += 0.1f;
      wave++;
   }
}
... to be continued ...
 	        

Eine erfrischend kurze Methode. Sind alle Invader explodiert und hat das Schiff noch mindestens ein Leben, befüllen wir die Simulation neu. Dazu löschen wir alle vorhandenen Blöcke und Schüsse und setzen shipShot auf null. Dann merken wir uns die aktuelle Schiffsposition. Diese wird ja im folgenden Aufruf der Methode populate auf (0,0,0) gesetzt, selbiges machen wir für die Anzahl der Leben. Nach Aufruf der populate-Methode ist die Simulation mit neuen Invadern und Blöcken befüllt und besitzt eine neue Instanz der Klasse Ship. Dieser setzen wir die letzte Position und speichern die Anzahl der Leben. Danach gehen wir den Multiplier an und erhöhen diesen um 0.1. Dies entspricht einer Erhöhung der Invader Geschwindigkeit um zehn Prozent. Abschließend erhöhen wir noch den Wave Counter, womit die Simulation für eine neue Runde bereit ist.

Als letztes Puzzlestück müssen wir uns noch anschauen, wie das Schiff bewegt wird und wir einen Schuss abfeuern können. Beginnen wir mit der Bewegung nach links:

... continued ...
public void moveShipLeft(float delta, float scale) 
{	
   if( ship.isExploding )
      return;
	
   ship.position.x -= delta * Ship.SHIP_VELOCITY * scale;
   if( ship.position.x < PLAYFIELD_MIN_X )
      ship.position.x = PLAYFIELD_MIN_X;
} 
... to be continued ...

Als Parameter erhalten wir die Delta Time, sowie einen Faktor scale. Was es mit dem zweiten auf sich hat, erfahren wir gleich. Zuerst prüfen wir aber, ob das Schiff explodiert. Ist das der Fall, brauchen wir gar nichts machen, da sich ein explodiertes Schiffe nicht mehr bewegt.

Ist das Schiff nicht zerstört worden, wird es nach links verschoben. Dazu multiplizieren wir wie gewohnt die Delta Time mit der Schiffsgeschwindigkeit. Zusätzlich multiplizieren wir auch noch den Faktor scale dazu. Dieser kommt von außen und bewegt sich im Bereich (0,1). Aufmerksame Leser werden erahnen, woher der Wert kommt: der Accelerometer gibt uns diesen. Mehr dazu später im Kapitel GameLoop, wo wir die Verarbeitung der Accelerometerdaten zur Steuerung des Schiffes besprechen.

Als letztes wird kontrolliert, ob das Schiff das Spielfeld verlassen hat. Ist dies der Fall, setzen wir es auf den letzten erlaubten Punkt auf der x-Achse zurück.

Für die Bewegung nach rechts gibt es eine analoge Methode, die ich hier nicht anführe, da sie das Gleiche, nur in die andere Richtung macht.

Zu guter Letzt die Methode zum Abfeuern eines Schusses:

... continued ...
public void shot() 
{	
   if( shipShot == null && !ship.isExploding )
   {
      shipShot = new Shot( ship.position, false );			
      shots.add( shipShot );
      if( listener != null )
         listener.shot();
   }
}
		

Zuerst wird geprüft, ob es bereits einen Schuss auf das Schiff gibt, bzw. ob das Schiff explodiert ist. Ist dies nicht der Fall, erstellen wir einen neuen Schuss an der aktuellen Schiffsposition, geben ihn in die Liste shots und signalisieren dem Listener, dass ein Schuss abgefeuert wurde. Das war es auch schon!

Damit haben wir alle Klassen der Simulation besprochen. Abgesehen von der Eingabe der Accelerometerdaten und Touch-Events, haben wir hiermit eine voll funktionstüchtige Spielewelt, die unabhängig von jeglicher anderen Komponente funktioniert. Unser Ziel ist also erreicht. Was noch bleibt, ist das Schreiben eines Renderers, der unsere Simulation zeichnet, sowie des GameLoops und des Start- und Game Over-Screens. Widmen wir uns zuerst dem Renderer, die größte Klasse nach der Simulation.

Das Rendering

Nachdem wir die Simulation so hübsch gekapselt haben, wollen wir das Ganze jetzt auch auf den Bildschirm zaubern. Dazu brauchen wir für jedes Element im Spiel verschiedene Ressourcen, d.h. Meshes und Texturen. Bevor wir uns Code anschauen, widmen wir uns kurz der Erstellung der Ressourcen.

Am Spielfeld gibt es vier verschiedene, sichtbare Elemente: das Schiff, die Invader, Schüsse und Blöcke. Für alle vier brauchen wir ein Mesh und eventuell auch Texturen. Ich habe für den Space Invaders-Klon Wings3D verwendet, ein Polygonmodeller zum Nulltarif. Für die Erstellung der Texturen habe ich Gimp verwendet, eine Open-Source Grafikapplikation. Der Einfachheit halber gibt es nur ein einziges Modell für Invader, eine fliegende Untertasse. Für das Schiff gibt es auch ein kleines Mesh. Die Blöcke habe ich als abgeflachte Würfel modelliert und die Schüsse sind ebenfalls kleine Würfel. Hier ein paar Screenshots der Modelle in Wings3D, sowie die verwendeten Texturen:

Datei:wingsship.png Datei:wingsinvader.png Datei:wingsblock.png Datei:wingsshot.png Datei:shipTextur.png Datei:invaderTextur.png

Wie zu erkennen ist, sind die einzelnen Meshes schon richtig skaliert. Ein Feld des Gitters hat die Abmessung 1x1. Texturen gibt es nur für Invader und das Schiff, Blöcke und Schüsse färben wir später über glColor4f ein. Alle Meshes besitzen Normalen zur Lichtberechnung, die wir natürlich auch verwenden wollen.

Zusätzlich zu den Spielobjekten brauchen wir auch noch einen hübschen Hintergrund als Grundlage der Darstellung. Dazu hat mein schwedischer Freund Zire (auch bekannt als Killergoat from hell) ein hübsches Bild seines Spieles Gods and Idols beigesteuert, ein Dankeschön an dieser Stelle.

Datei:planet.png

Für hübsche Explosionen habe ich einen frei im Netz erhältlichen Generator verwendet. Das Ergebnis sieht so aus:

Datei:explode.png

Man sieht mehrere Animationsphasen der Explosion. Wir werden später sehen, wie wir diese auf ein Mesh bekommen und die Explosionen damit zeichnen.

Als Font zur Darstellung der Punkte, der Leben und der aktuellen Welle, habe ich mir aus dem Internet einen frei verwendbaren Font namens "Cosmic Alien" besorgt.

Wie die Meshes und das Texturieren im Detail funktionieren, kann ich hier leider nicht erklären, das würde den Rahmen des Tutorials mehr als sprengen. Im Netz gibt es dazu aber mehr als genug Material, ich verweise den geneigten Leser daher an dieser Stelle auf Google.

Mit all den Ressourcen bewaffnet, wenden wir uns jetzt dem Renderer zu.

Renderer Klasse

Ziel der Klasse ist es, erstens alle benötigten Ressourcen zu laden und zu verwalten und zweitens, die Simulation mit diesen Ressourcen zu rendern. Darunter fällt das Zeichnen des Hintergrunds, der Statistiken, sowie der Invader, des Schiffes, der Schüsse, der Explosionen und der Blöcke. Nach getaner Arbeit soll der Renderer auch wieder all die Ressourcen freigeben können.

Unsere Spielwelt werden wir in 3D zeichnen, mit einer direktionalen Lichtquelle, die alle Elemente beleuchtet. Die Kamera soll dabei immer etwas über dem Schiff schweben und leicht nach unten auf das Spielfeld blicken. Außerdem soll sie sich mit dem Schiff mitbewegen. Die Invader sollen sich drehen, das Schiff soll sich je nach Ausrichtung des Android-Geräts neigen. Als kleine Motivation hier ein Bild der fertigen Szene:

Datei:gameworld.png

Schauen wir uns zuerst die Attribute der Klasse an:

public class Renderer 
{
   Mesh shipMesh;
   Textur shipTextur;
   Mesh invaderMesh;
   Textur invaderTextur;
   Mesh blockMesh;
   Mesh shotMesh;
   Mesh backgroundMesh;
   Textur backgroundTextur;
   Mesh explosionMesh;
   Textur explosionTextur;
   Font font;
   Text text;
   float invaderAngle = 0;
   int lastScore = 0;
   int lastLives = 0;
   int lastWave = 0;
... to be continued ...

Für das Schiff und die Invader haben wir jeweils ein Mesh, sowie eine Textur. Blöcke und Schüsse besitzen nur ein Mesh, die färben wir später händisch ein. Für den Hintergrund haben wir ebenfalls ein Mesh, sowie eine Textur, selbiges gilt für Explosionen. Da wir auch ein paar Statistiken anzeigen wollen, besitzt der Renderer auch einen Font, sowie eine Text-Klasse. Der Member invaderAngle wird zur Speicherung des aktuellen Drehwinkels der Invader benötigt. Die restlichen drei Attribute merken sich die letzten Werte für Leben, Welle und Punktestand. Die verwenden wir später, um Änderungen dieser Werte zu registrieren. Nur bei Änderungen bauen wir die Text Klasse neu, da dies dem Garbage Collector besser passt.

Bevor wir irgendetwas zeichnen, müssen wir zuerst einmal all unsere Ressourcen laden. Das Ganze machen wir relativ überraschungslos im Konstruktor:

... continued ...
public Renderer( GL10 gl, GameActivity activity )
{
   try
   {
      shipMesh = MeshLoader.loadObj(gl, activity.getAssets().open( "ship.obj" ) );
      invaderMesh = MeshLoader.loadObj( gl, activity.getAssets().open( "invader.obj" ) );
      blockMesh = MeshLoader.loadObj( gl, activity.getAssets().open( "block.obj" ) );
      shotMesh = MeshLoader.loadObj( gl, activity.getAssets().open( "shot.obj" ) );
		
      backgroundMesh = new Mesh( gl, 4, false, true, false );
      backgroundMesh.texCoord(0, 0);
      backgroundMesh.vertex(-1, 1, 0 );
      backgroundMesh.texCoord(1, 0);
      backgroundMesh.vertex(1, 1, 0 );
      backgroundMesh.texCoord(1, 1);
      backgroundMesh.vertex(1, -1, 0 );
      backgroundMesh.texCoord(0, 1);
      backgroundMesh.vertex(-1, -1, 0 );
		
      explosionMesh = new Mesh( gl, 4 * 16, false, true, false );
      for( int row = 0; row < 4; row++ )
      {
         for( int column = 0; column < 4; column++ )
         {
            explosionMesh.texCoord( 0.25f + column * 0.25f, 0 + row * 0.25f );
            explosionMesh.vertex( 1, 1, 0 );
            explosionMesh.texCoord( 0 + column * 0.25f, 0 + row * 0.25f );
            explosionMesh.vertex( -1, 1, 0 );
            explosionMesh.texCoord( 0f + column * 0.25f, 0.25f + row * 0.25f );
            explosionMesh.vertex( -1, -1, 0 );
            explosionMesh.texCoord( 0.25f + column * 0.25f, 0.25f + row * 0.25f );
            explosionMesh.vertex( 1, -1, 0 );		
         }
      }					
   }
   catch( Exception ex )
   {
      Log.d( "Space Invaders", "couldn't load meshes" );
      throw new RuntimeException( ex );
   }
	
   try
   {					
      Bitmap bitmap = BitmapFactory.decodeStream( activity.getAssets().open( "ship.png" ) );
      shipTextur = new Textur( gl, bitmap, TexturFilter.MipMap, TexturFilter.Nearest, TexturWrap.ClampToEdge, TexturWrap.ClampToEdge );
      bitmap.recycle();

      bitmap = BitmapFactory.decodeStream( activity.getAssets().open( "invader.png" ));
      invaderTextur = new Textur( gl, bitmap, TexturFilter.MipMap, TexturFilter.Nearest, TexturWrap.ClampToEdge, TexturWrap.ClampToEdge );
      bitmap.recycle();

      bitmap = BitmapFactory.decodeStream( activity.getAssets().open( "planet.jpg" ) );
      backgroundTextur = new Textur( gl, bitmap, TexturFilter.Nearest, TexturFilter.Nearest, TexturWrap.ClampToEdge, TexturWrap.ClampToEdge );
      bitmap.recycle();
			
      bitmap = BitmapFactory.decodeStream( activity.getAssets().open( "explode.png" ) );
      explosionTextur = new Textur( gl, bitmap, TexturFilter.MipMap, TexturFilter.Nearest, TexturWrap.ClampToEdge, TexturWrap.ClampToEdge );
      bitmap.recycle();
   }
   catch( Exception ex )
   {
      Log.d( "Space Invaders", "couldn't load Texturs" );
      throw new RuntimeException( ex );
   }
	
   font = new Font( gl, activity.getAssets(), "font.ttf", 16, FontStyle.Plain );
   text = font.newText( gl );
	
   float[] lightColor = { 1, 1, 1, 1 };
   float[] ambientLightColor = {0.0f, 0.0f, 0.0f, 1 };		
   gl.glLightfv( GL10.GL_LIGHT0, GL10.GL_AMBIENT, ambientLightColor, 0 );
   gl.glLightfv( GL10.GL_LIGHT0, GL10.GL_DIFFUSE, lightColor, 0 );
   gl.glLightfv( GL10.GL_LIGHT0, GL10.GL_SPECULAR, lightColor, 0 );
}
... to be continued ...

Viel Code zum Laden. Gehen wir es der Reihe nach durch. Zuerst laden wir die Meshes für das Schiff, Invader, Blöcke und Schüsse. Diese liegen, wie gehabt, als Assets vor, dementsprechend laden wir sie auch. Als nächstes basteln wir uns ein Mesh für das Hintergrundbild. Dieses ist ein Rechteck, bestehend aus zwei Dreiecken, die wir auf die ganze HintergrundTextur mappen. Die linke obere Ecke des Rechtecks liegt bei (-1, 1, 0), die untere Ecke bei (1, -1, 0). Wir erinnern uns, dass, wenn wir keine Projektionsmatrix setzen, der sichtbare Teil des Koordinatensystems am Bildschirm dieselben Abmessungen hat. Später müssen wir also lediglich die Projektionsmatrix auf identity setzen und die beiden Dreiecke zeichnen, schon haben wir das Hintergrundbild über den ganzen Bildschirm gespannt.

Als nächstes bauen wir das Mesh für die Explosionen. Hier wird es wieder ein wenig schwieriger. In der Explosions-Textur befinden sich 4 * 4 = 16 Animationsstufen der Explosion. Für jede dieser Animationsstufen bauen wir im Mesh ein eigenes Rechteck aus zwei Dreiecken, beginnend bei der obersten linken Animationsstufe. Ein solches Rechteck hat die Abmessungen 1x1, was ungefähr den Abmessungen der Invader bzw. des Schiffes in der x-y-Achse entspricht. Insgesamt haben wir in dem Mesh also 16 Rechtecke in Sequenz der Animation der Explosion. Diesen Umstand nutzen wir dann später beim Zeichnen der Explosionen aus. Die Technik nennt man auch Sprite-Rendering und sie funktioniert ähnlich, wie in einem Trickfilm. Mehr dazu später.

Nachdem wir jetzt alle Meshes geladen bzw. erstellt haben, können wir uns den Texturen widmen. Auch diese laden wir wieder unspektakulär aus den Assets. Man beachte hier die Angabe von Mipmapping beim Laden. Dies führt zu einem großen Performance-Gewinn und sollte so gut wie immer verwendet werden.

Abschließend laden wir noch den Font, den wir verwenden wollen und erstellen eine neue Instanz der Klasse Text, die wir später zum Rendern der Statistiken verwenden werden.

Licht brauchen wir auch, darum setzen wir die Lichttyp-Farben für die Lichtquelle 0 schon einmal im Konstruktor. Wie im Licht-Kapitel beschrieben, müssen wir diese Werte nicht jedes Mal neu setzen, OpenGL merkt sich das für uns.

Nachdem jetzt alles geladen ist, können wir uns gleich das Rendering selbst anschauen. Dazu besitzt der Renderer die Methode render, die eine GL10 Instanz, die GameActivity und die Instanz der Simulation entgegennimmt. Die Simulation brauchen wir natürlich, um zu wissen, wo welches Objekt gezeichnet werden soll:

... continued ...
public void render( GL10 gl, GameActivity activity, Simulation simulation )
{		
   gl.glClear( GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT );
   gl.glViewport( 0, 0, activity.getViewportWidth(), activity.getViewportHeight() );		    
		
   gl.glEnable( GL10.GL_TEXTUR_2D );				
   renderBackground( gl );		
		
   gl.glEnable( GL10.GL_DEPTH_TEST );
   gl.glEnable( GL10.GL_CULL_FACE );		
		
   setProjectionAndCamera( gl, simulation.ship, activity );
   setLighting( gl );
			
   renderShip( gl, simulation.ship, activity );
   renderInvaders( gl, simulation.invaders );

   gl.glDisable( GL10.GL_TEXTUR_2D );
   renderBlocks( gl, simulation.blocks );

   gl.glDisable( GL10.GL_LIGHTING );
   renderShots( gl, simulation.shots );

   gl.glEnable( GL10.GL_TEXTUR_2D );
   renderExplosions( gl, simulation.explosions );

   gl.glDisable( GL10.GL_CULL_FACE );
   gl.glDisable( GL10.GL_DEPTH_TEST );

   set2DProjection(gl, activity);
	
   gl.glEnable( GL10.GL_BLEND );
   gl.glBlendFunc( GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA );
   gl.glTranslatef( 0, activity.getViewportHeight(), 0 );
   if( simulation.ship.lives != lastLives || simulation.score != lastScore || simulation.wave != lastWave )
   {
      text.setText( "lives: " + simulation.ship.lives + " wave: " + simulation.wave + " score: " + simulation.score );
      lastLives = simulation.ship.lives;
      lastScore = simulation.score;
      lastWave = simulation.wave;
   }
   text.render();
   gl.glDisable( GL10.GL_BLEND);
   gl.glDisable( GL10.GL_TEXTUR_2D );
	
   invaderAngle+=activity.getDeltaTime() * 90;
   if( invaderAngle > 360 )
      invaderAngle -= 360;
}	  
... to be continued ...

Wir beginnen damit, den Framebuffer und den Z-Buffer zu löschen. Danach setzen wir, wie gehabt, den Viewport. Als nächstes wollen wir den Hintergrund zeichnen. Dazu müssen wir zuerst Texturing einschalten und springen dann in eine Methode, die das Rendering selbst übernimmt. Diese werden wir uns später anschauen.

Nachdem der Hintergrund kein 3D-Objekt ist und immer ganz gezeichnet werden soll, haben wir bis zu diesem Zeitpunkt den Z-Buffer noch nicht eingeschaltet. Das machen wir jetzt, da wir die 3D-Objekte, wie Invader und das Schiff, zeichnen wollen. Auch schalten wir so genanntes Backface Culling dazu. Dieses sorgt dafür, dass nur jene Dreiecke gezeichnet werden, die in Richtung des Betrachters "blicken".

Bevor wir unsere 3D-Objekte zeichnen, müssen wir noch die Kamera einrichten und die Lichtquelle setzen. Das übernehmen die Methoden setProjectionAndCamera und setLighting für uns, die wir uns später im Detail anschauen werden.

Wir haben jetzt also den Z-Buffer aktiviert, Backface Culling eingeschaltet, die Projektions- und Kamera-Matrix gesetzt, sowie das Licht eingeschalten. Auch Texturing ist noch in Betrieb. Jetzt sind wir bereit, die 3D-Objekte zu zeichnen. Das Zeichnen des Schiffes und der Invader übernehmen die Methoden renderShip und renderInvader. Als nächstes schalten wir Texturing aus, da wir die Blöcke und die Schüsse zeichnen. Diese besitzen ja keine Texturen. Hier helfen uns die Methoden renderBlocks und renderShots aus. Bevor wir die Schüsse zeichnen, schalten wir das Licht wieder aus, diese sollen ja keinen Schatten haben. Als letzte 3D-Objekte zeichnen wir die Explosionen. Für diese müssen wir Texturing wieder einschalten. Wir zeichnen sie deshalb als letztes in der Reihenfolge, da diese transparent sind. Wie wir uns erinnern, müssen wir transparente Objekte immer zuletzt zeichnen, da es sonst zu Problemen mit dem Z-Buffer kommt. Auch hier haben wir wieder eine handliche Methode namens renderExplosions, die uns die Arbeit abnimmt.

Alle 3D-Objekte sind gezeichnet, daher können wir Backface Culling und den Z-Buffer wieder ausschalten. Wir wollen nun die Statistiken zeichnen, die ja 2D-Elemente sind. Dazu setzen wir eine entsprechende Projektionsmatrix, die für ein 2D-Koordinaten-System sorgt. Das macht die Methode set2DProjection. Als nächstes schalten wir Blending ein, da unser Text transparente Stellen besitzt und setzen eine Transformation, damit der Text in der oberen linken Ecke des Bildschirms gezeichnet wird. Bevor wir den Text zeichnen, prüfen wir noch, ob sich die Statistikwerte, im Vergleich zu den zuletzt gespeicherten Werten, geändert haben. Ist dies der Fall, sagen wir der Text-Instanz, dass sie einen neuen String darstellen soll und merken uns die neuen Werte. Die Methode Text.setText baut intern auf Basis des übergebenen Strings neue Dreiecke zusammen, was eine etwas kostspielige Operation ist, wenn man es jedes Frame macht. Auch wäre der Garbage Collector wenig erfreut, wenn wir in jedem Frame einen neuen String erzeugen. Darum machen wir das hier etwas umständlich. Der Text ist also gesetzt und wir können ihn einfach zeichnen. Danach schalten wir Blending wieder ab.

Zu guter Letzt erhöhen wir das Attribut invaderAngle noch zeitbasiert. Diesen verwenden wir als Rotationswinkel um die y-Achse für die Invader, die sich damit drehen. In einer Sekunde drehen sie sich dabei um 90 Grad, wofür die Multiplikation der Delta Time mit 90 sorgt.

Machen wir uns noch schnell bewusst, welchen Status OpenGL nach verlassen der render Methode hat. Z-Buffer, Beleuchtung, Texturing und Blending sind ausgeschalten. Die Projektions-Matrix ist auf eine 2D-Projektion gesetzt, die Model-View-Matrix auf die Transformation zur Verschiebung in die obere linke Ecke. OpenGL behält diesen Status bis zum nächsten Render-Aufruf. Es ist immer wichtig, sich über diesen Status im Klaren zu sein und ihn so sauber wie möglich zu halten, es können sonst unerklärbare Probleme auftreten, wie das Fehlen von Texturen, die eigentlich nur daraus resultieren, dass das Texturing nicht eingeschalten ist. Selbiges gilt auch für die Matrizen, die dafür sorgen können, dass Objekte aus dem Blickfeld verschoben werden, nur weil man vergessen hat, zuerst eine identity-Matrix zu laden und damit den alten Inhalt zu löschen.

Schauen wir uns jetzt noch die in der render-Methode verwendeten Helfer-Methoden an. Wir machen das in der Reihenfolge des Auftretens in render:

... continued ...
private void renderBackground( GL10 gl )
{
   gl.glMatrixMode( GL10.GL_PROJECTION );
   gl.glLoadIdentity();
   gl.glMatrixMode( GL10.GL_MODELVIEW );
   gl.glLoadIdentity();
   backgroundTextur.bind();
   backgroundMesh.render(PrimitiveType.TriangleFan);
} 
... to be continued ...

Beim Rendering des Hintergrunds setzen wir zuerst Projektions- und Model-View-Matrix auf Identity. Damit schaffen wir ein 2D-Koordinatensystem, dessen sichtbarer Teil in x-y bei (-1,1) bis (1,-1) geht. Unser Mesh für den Hintergrund hat genau dieselben Abmessungen. Wir brauchen daher nur mehr die Hintergrund-Textur zu binden und das Mesh zu zeichnen, schon haben wir den ganzen Bildschirm mit dem Hintergrundbild gefüllt. Einen kleinen Fehler hat das ganze: Das Hintergrundbild hat die Abmessungen 512x512 Pixel. Der Bildschirm hat diese hundertprozentig nicht, das Bild wird also gestaucht. Als Übung könnt ihr ja versuchen, dieses Problem zu lösen.

... continued ...
private void setProjectionAndCamera( GL10 gl, Ship ship, GameActivity activity )
{
   gl.glMatrixMode( GL10.GL_PROJECTION );
   gl.glLoadIdentity();
   float aspectRatio = (float)activity.getViewportWidth() / activity.getViewportHeight();
   GLU.gluPerspective( gl, 67, aspectRatio, 1, 1000 );					

   gl.glMatrixMode( GL10.GL_MODELVIEW );
   gl.glLoadIdentity();
   GLU.gluLookAt( gl, ship.position.x, 6, 2, ship.position.x, 0, -4, 0, 1, 0 );
}
... to be continued ...

Auch hier passiert nichts Neues. Wie im Kapitel zu Projektionen beschrieben, setzen wir zuerst eine perspektivische Projektions-Matrix. Die Model-View-Matrix setzen wir über GLU.gluLookAt auf eine Kamera-Matrix. Die Kamera befindet sich dabei etwas oberhalb des Schiffes und schaut schräg nach unten auf das Spielfeld. Interessant dabei ist, dass die x-Position der Kamera von der x-Position des Schiffes abhängt. Mit diesem Kniff lassen wir die Kamera dem Schiff folgen.

... continued ...
float[] direction = { 1, 0.5f, 0, 0 };	
private void setLighting( GL10 gl )
{
   gl.glEnable( GL10.GL_LIGHTING );
   gl.glEnable( GL10.GL_LIGHT0 );				
   gl.glLightfv( GL10.GL_LIGHT0, GL10.GL_POSITION, direction, 0 );
   gl.glEnable( GL10.GL_COLOR_MATERIAL );		
} 
... to be continued ...

Auch das Setzen des Lichtes birgt keine Neuerungen. Zuerst schalten wir das Licht ein, dann, im Speziellen, das Licht mit Nummer 0. Als nächstes setzen wir die Richtung des Lichtes, das ein direktionales ist. Das Array, das die Richtung hält, instanzieren wir dabei nicht jedes Mal neu, wir denken an den Garbage Collector. Das Licht kommt in diesem Fall von rechts oben. Abschließend schalten wir Color Materials noch ein und sind fertig.

... continued ...
private void renderShip( GL10 gl, Ship ship, GameActivity activity )
{
   if( ship.isExploding )
      return;

   shipTextur.bind();
   gl.glPushMatrix();
   gl.glTranslatef( ship.position.x, ship.position.y, ship.position.z );
   gl.glRotatef( 45 * (-activity.getAccelerationOnYAxis() / 5), 0, 0, 1 );
   gl.glRotatef( 180, 0, 1, 0 );
   shipMesh.render(PrimitiveType.Triangles);
   gl.glPopMatrix();
}
... to be continued ...

Explodiert das Schiff gerade, brauchen wir es natürlich nicht zeichnen. Das Zeichnen der entsprechenden Explosion regeln wir dann in renderExplosions. Explodiert das Schiff nicht, binden wir zuerst die Textur für das Schiff. Danach folgt, was immer folgen sollte, wir pushen die Model-View-Matrix (die wir zuvor in setProjectionAndCamera aktiviert haben). Die nächsten drei Aufrufe verschieben und rotieren das Schiff. Wir erinnern uns wieder, daas wir die Reihenfolge umkehren müssen. Beginnen wir also bei der letzten Transformation. Diese rotiert das Schiff um 180 Grad um die y-Achse. Da ich das Schiff in Wings3D so gemacht habe, dass es entlang der positiven z-Achse schaut, müssen wir es hier in die entgegengesetzte Richtung rotieren. Als nächstes sorgen wir mit einer weiteren Rotation für einen schönen Anblick. Davon abhängig, wie das Gerät entlang seiner y-Achse geneigt ist, rotieren wir das Schiff um die z-Achse. Hält man das Geräte im Landscape-Modus und neigt es wie ein Lenkrad nach links und rechts, schlägt der Accelerometer entlang der y-Achse im Bereich (-10,10) aus. Diesen Werte negieren wir (wegen der Rotationsrichtung) und dividieren ihn durch 5, womit wir in einen Wertebereich von (-2,2) kommen. Diese Werte multiplizieren wir dann noch mit 45 Grad, womit wir auf den wirklichen Rotationswinkel des Schiffes um die z-Achse kommen. Dieser Winkel liegt somit im Bereich (45*-2, 45*2). Diese Maximalwerte werden erreicht, wenn man das Gerät im Portrait-Modus hält. Als letzte Transformation verschieben wir das rotierte Schiff noch an seine Position im Spielfeld. Es folgt das Rendern des Meshes und das popen der Model-View-Matrix. Die hat danach wieder nur die Kamera-Matrix gesetzt und wir können die nächsten Objekte zeichnen.

... continued ...

private void renderInvaders( GL10 gl, ArrayList<Invader> invaders )
{
   invaderTextur.bind();
   for( int i = 0; i < invaders.size(); i++ )
   {
      Invader invader = invaders.get(i);
      gl.glPushMatrix();
      gl.glTranslatef( invader.position.x, invader.position.y, invader.position.z );
      gl.glRotatef( invaderAngle, 0, 1, 0 );
      invaderMesh.render(PrimitiveType.Triangles);
      gl.glPopMatrix();
   }
}   
... to be continued ...

Beim Zeichnen der Invader gehen wir ähnlich vor. Zuerst setzen wir die Invader-Textur. Danach gehen wir über all Invader in der Simulation (die wir ja als Parameter in der Renderer.render Methode bekommen haben). Für jeden Invader pushen wir zuerst wieder die Model-View-Matrix, transformieren den Invader an seinen richtigen Platz und zeichnen das Invader-Mesh. Danach popen wir wieder die Model-View-Matrix. Wie ersichtlich, rotieren wir jeden Invader auch um die y-Achse. Den Winkel erhöhen wir in der Renderer.render-Methode zeitbasiert.

... continued ...
private void renderBlocks( GL10 gl, ArrayList<Block> blocks )
{		
   gl.glEnable( GL10.GL_BLEND );
   gl.glBlendFunc( GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA );
   gl.glColor4f( 0.2f, 0.2f, 1, 0.7f );
   for( int i = 0; i < blocks.size(); i++ )
   {
      Block block = blocks.get(i);
      gl.glPushMatrix();
      gl.glTranslatef( block.position.x, block.position.y, block.position.z );
      blockMesh.render(PrimitiveType.Triangles);
      gl.glPopMatrix();
   }
   gl.glColor4f( 1, 1, 1, 1 );
   gl.glDisable( GL10.GL_BLEND );
} 
... to be continued ...

Wie auf dem Bild oben ersichtlich, scheinen die Blöcke leicht durch. Deswegen aktivieren wir zuerst Blending. Auch Textur haben wir für die Blöcke keine, Texturing ist zu diesem Zeitpunkt schon deaktiviert. Wir färben die Blöcke daher händisch mit glColor4f mit einem hübschen Blau ein. Danach gehen wir wieder über alle Blöcke, pushen die Model-View-Matrix, transformieren den Block, rendern das Block-Mesh und popen die Model-View-Matrix. Abschließend setzen wir die Zeichenfarbe wieder auf weiß und schalten Blending aus.

... continued ...
private void renderShots( GL10 gl, ArrayList<Shot> shots )
{
   gl.glColor4f( 1, 1, 0, 1 );
   for( int i = 0; i < shots.size(); i++ )
   {
      Shot shot = shots.get(i);
      gl.glPushMatrix();
      gl.glTranslatef( shot.position.x, shot.position.y, shot.position.z );
      shotMesh.render(PrimitiveType.Triangles);
      gl.glPopMatrix();
   }		
   gl.glColor4f( 1, 1, 1, 1 );
}
... to be continued ...

Auch die Schüsse sind schnell gezeichnet. Farbe setzen, über alle Schüsse gehen, push, transform, render, pop und die Farbe wieder zurücksetzen. Langsam wird es langweilig.

private void renderExplosions(GL10 gl, ArrayList<Explosion> explosions) 
{	
   gl.glEnable( GL10.GL_BLEND );
   gl.glBlendFunc( GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA );
   explosionTextur.bind();
   for( int i = 0; i < explosions.size(); i++ )
   { 
      Explosion explosion = explosions.get(i);
      gl.glPushMatrix();
      gl.glTranslatef( explosion.position.x, explosion.position.y, explosion.position.z );
      explosionMesh.render(PrimitiveType.TriangleFan, (int)((explosion.aliveTime / Explosion.EXPLOSION_LIVE_TIME) * 15) * 4, 4);
      gl.glPopMatrix();
   }			
   gl.glDisable( GL10.GL_BLEND );
}

Für die Explosionen brauchen wir wieder Blending, das wir gleich einmal einschalten. Dann binden wir die Explosions-Textur und gehen über jede Explosion. Hier wird es interessant. Wie gehabt, pushen wir zuerst und transformieren dann. Beim Rendern des Meshes wenden wir aber einen kleinen Trick an. Abhängig davon, wie lange die Explosion schon am Leben ist, zeichnen wir nur eines der Rechtecke im Explosions-Mesh. Wir erinnern uns, dass wir dort ja für die 16 Animationsphasen jeweils ein Rechteck definiert haben. Die Mesh-Klasse bietet neben der einfachen Mesh.render(PrimitiveType type)-Methode auch noch eine zweite, die es erlaubt, einen Offset in das Mesh anzugeben, sowie die Anzahl der Vertices, die ab diesem Offset verwendet werden sollen. Wir errechnen uns einfach den Offset des passenden Rechtecks und verwenden dessen vier Vertices zum Zeichnen der aktuellen Explosion. Das Offset ergibt sich aus der Lebensdauer der Explosion, geteilt durch die maximale Lebensdauer, was einen Wert zwischen 0 und 1 ergibt. Diesen Wert multiplizieren wir dann mit 15 und casten in auf einen int. Nach dieser einfachen Formel lässt sie die Animationsstufe sehr einfach berechnen. Ist die Explosion z.B. seit einer Sekunde am Leben, wählen wir Rechteck Nummer (int)(1.0 / 2 * 15) = 7. Dies multiplizieren wir noch mit vier, da wir das Offset in Vertices angeben müssen. Und schon haben wir unsere Explosionen animiert! Natürlich popen wir dann wieder die Model-View-Matrix und schalten Blending wieder aus.

Damit haben wir eigentlich das ganze Rendering der Simulation besprochen. Wir brauchen nur noch eine Methode, die die ganzen Meshes und Texturen aufräumt:

... continued ...
public void dispose( )
{
   shipTextur.dispose();
   invaderTextur.dispose();
   backgroundTextur.dispose();
   explosionTextur.dispose();
   font.dispose();
   text.dispose();
   explosionMesh.dispose();
   shipMesh.dispose();
   invaderMesh.dispose();
   shotMesh.dispose();
   blockMesh.dispose();
   backgroundMesh.dispose();
}

Wir geben einfach alle geladenen Texturen und Meshes wieder frei, keine große Sache.

Der Renderer in Kombination mit einer Simulations-Instanz wäre jetzt schon vollständig einsetzbar. Wir müssten das Ganze nur in eine GameActivity packen, die Simulation in jedem Frame aktualisieren und den Renderer anwerfen. Bevor wir dass aber tun, wollen wir uns noch kurz um den Sound kümmern. Soll ja schließlich auch krachen.

Sound und Musik

Dieses Kapitel wird wieder erfrischend einfach. Ähnlich dem Renderer brauchen wir eine Klasse, die uns das Laden der Soundeffekt und Musik schön kapselt. Auch muss sie uns die Möglichkeit bieten on-demand einen Soundeffekt abzuspielen, bzw. die Musik zu stoppen. Wir basteln dazu eine Klasse namens SoundManager, die wir ins gleich im Detail anschauen werden.

Die Soundeffekte für den Space Invaders-Klon habe ich mit einem netten Tool namens sfxr gemacht. Dieses erlaubt das einfache erstellen von 8-Bit-artigen Soundeffekten mit einem einzigen Mausklick. Die so entstandenen Effekte habe ich ein wenig mit Audacity nachbearbeitet. Es gibt einen Effekt für die Explosionen und einen Effekt für die Schüsse.

Die Musik habe ich vor Urzeiten daheim mit Cubase eingespielt. Ist ein unaufregender Rock Track.

SoundManager Klasse

Wie im Renderer müssen wir zuerst einmal alles laden. Das machen wir wieder im Konstruktor:

public class SoundManager 
{
   SoundPool soundPool;
   AudioManager audioManager;
   MediaPlayer mediaPlayer;
   int shotID;
   int explosionID;
	
   public SoundManager( GameActivity activity )
   {
      soundPool = new SoundPool( 10, AudioManager.STREAM_MUSIC, 0);
      audioManager = (AudioManager)activity.getSystemService(Context.AUDIO_SERVICE);
      activity.setVolumeControlStream(AudioManager.STREAM_MUSIC);
		  
      try
      {
         AssetFileDescriptor descriptor = activity.getAssets().openFd( "shot.wav" );
         shotID = soundPool.load( descriptor, 1 );
         descriptor = activity.getAssets().openFd( "explosion.wav" );
         explosionID = soundPool.load( descriptor, 1 );
      }
      catch( Exception ex )
      {
         Log.d( "Sound Sample", "couldn't load sound 'shot.wav'" );
         throw new RuntimeException( ex );
      }
		
      mediaPlayer = new MediaPlayer();
      try
      {
         AssetFileDescriptor descriptor = activity.getAssets().openFd( "8.12.mp3" );
         mediaPlayer.setDataSource( descriptor.getFileDescriptor() );
         mediaPlayer.prepare();
         mediaPlayer.setLooping(true);
         mediaPlayer.start();			
      }
      catch( Exception ex )
      {
         ex.printStackTrace();
         Log.d( "Sound Sample", "couldn't load music 'music.mp3'" );
         throw new RuntimeException( ex );
      }
   }
... to be continued ...

Als Attribute halten wir uns je einen SoundPool, einen AudioManager einen MediaPlayer, sowie die später initialisierten IDs der beiden Soundeffekte. Im Konstruktor bauen wir zuerst einen SoundPool, der zehn Effekte gleichzeitig spielen können soll. Als nächstes holen wir uns den AudioManager, den brauchen wir später noch. Dann sagen wir Android, dass bei Betätigung der Volume-Tasten die Medienlautstärke geändert werden soll. Das ist wichtig, sonst wird nur die Klingeltonlautstärke über die Tasten geregelt.

Als nächstes laden wir die beiden Soundeffekte in den SoundPool. Diese liegen als Assets vor. Die beiden erhaltenen Ids, speichern wir in den entsprechenden Attributen.

Nach dem Instanzieren des MediaPlayers lassen wir diesen die Musik-Datei abspielen und zwar geloopt.

Natürlich gibt es noch ein paar andere Methoden:

... continued ...
public void playShotSound( )
{
   int volume = audioManager.getStreamVolume( AudioManager.STREAM_MUSIC );
   soundPool.play(shotID, volume, volume, 1, 0, 1);
}
	
public void playExplosionSound( )
{
   int volume = audioManager.getStreamVolume( AudioManager.STREAM_MUSIC );
   soundPool.play(explosionID, volume, volume, 1, 0, 1);
}
... to be continued ...

Zwei Methoden werden es uns später erlauben, die Effekte für Schüsse und Explosionen abzuspielen. Auch hier sollten wir schon alles kennen. Wir holen uns jeweils die aktuelle Medienlautstärke und spielen dann den Effekt mit der entsprechenden ID ab.

Zu guter Letzt müssen wir natürlich wieder aufräumen:

public void dispose( )
{
   soundPool.release();
   mediaPlayer.release();
}
 

Wir geben sowohl den SoundPool, als auch den MediaPlayer frei. Letzteres ist wichtig, da sonst die Musik auch nach dem Schließen der Applikation weiterläuft. Wir dürfen also später nicht vergessen, die SoundManager.dispose Methode aufzurufen.

SpaceInvaders Activity & Screens

Endlich können wir uns dem letzten Baustein unseres Spiels widmen, der Activity und den so genannten Screens. Ein Screen stellt einen Zustand im Spiel dar, in unserem Fall ist das der Start-Bildschirm, der das Spiellogo zeigt, außerdem eine Aufforderung, den Bildschirm zu berühren, den eigentlichen Spiel-Bildschirm, der die Simulation laufen lässt und rendert und den Game-Over-Bildschirm, der den erreichten Punktestand anzeigt. Die Aufteilung in Screens erlaubt es uns, die verschiedenen Zustände des Spiels in der eigentlichen Activity zu verwalten. Auf einen Screen folgt ein anderer. Ein neuer Screen wird aktiviert, sobald der aktuelle beendet ist. Ich habe dazu folgendes Interface definiert:

public interface GameScreen 
{
   public void update( GameActivity activity );
   public void render( GL10 gl, GameActivity activity );
   public boolean isDone( );
   public void dispose( );
}  

Die update Methode soll den Zustand des Screens aktualisieren. Darunter fällt z.B. das Laufen lassen der Simulation. Die Methode render sollte selbsterklärend sein. Die Methode isDone erlaubt es uns, den Screen zu fragen, ob er fertig ist und der nächste Screen angezeigt werden kann. Die Methode dispose werden wir aufrufen, wenn wir auf den nächsten Screen schalten, damit die Ressourcen des alten wieder freigegeben werden (SoundManager, Renderer).

Wie bereits erwähnt, besitzt unser Space Invaders-Klon drei Screens, die jeweils von GameScreen ableiten. Wir werden diese zuerst besprechen und dann, als letzten Punkt, die Activity betrachten, die all dies steuert.

StartScreen Klasse

Der Start-Bildschirm soll zum einen unser Hintergrundbild darstellen, sowie ein Logo und den Hinweis, dass der Benutzer den Schirm berühren soll. Der Screen wird beendet, sobald der Benutzer den Screen berührt hat. Das Ganze ist nicht allzu schwer zu implementieren, schauen wir es uns an:

public class StartScreen implements GameScreen
{	
   Mesh backgroundMesh;
   Textur backgroundTextur;
   Mesh titleMesh;
   Textur titleTextur;
   boolean isDone = false;
   SoundManager soundManager;
   Font font;
   Text text;
   String pressText = "Touch Screen to Start!";
... to be continued ...
 

Erst mal leiten wir von GameScreen ab. Als nächstes definieren wir uns ein paar Attribute. Dazu zählen die Textur und das Mesh für den Hintergrund, bzw. für das Logo. Ein Boolean namens isDone speichert, ob der Screen fertig ist. Einen SoundManager brauchen wir auch, da wir Hintergrundmusik abspielen wollen. Auch eine Instanz von Font und Text brauchen wir zur Touch-Aufforderung. Das letzte Attribut ist im Endeffekt nur eine Konstante, die den Aufforderungstext hält.

public StartScreen( GL10 gl, GameActivity activity )
{			
   backgroundMesh = new Mesh( gl, 4, false, true, false );
   backgroundMesh.texCoord(0, 0);
   backgroundMesh.vertex(-1, 1, 0 );
   backgroundMesh.texCoord(1, 0);
   backgroundMesh.vertex(1, 1, 0 );
   backgroundMesh.texCoord(1, 1);
   backgroundMesh.vertex(1, -1, 0 );
   backgroundMesh.texCoord(0, 1);
   backgroundMesh.vertex(-1, -1, 0 );
		
   titleMesh = new Mesh( gl, 4, false, true, false );
   titleMesh.texCoord(0, 0);
   titleMesh.vertex(-256, 256, 0);
   titleMesh.texCoord(1, 0);
   titleMesh.vertex(256, 256, 0);
   titleMesh.texCoord(1, 0.5f);
   titleMesh.vertex(256, 0, 0);
   titleMesh.texCoord(0, 0.5f);
   titleMesh.vertex(-256, 0, 0);

   try
   {
      Bitmap bitmap = BitmapFactory.decodeStream( activity.getAssets().open( "planet.jpg" ) );
      backgroundTextur = new Textur( gl, bitmap, TexturFilter.MipMap, TexturFilter.Nearest, TexturWrap.ClampToEdge, TexturWrap.ClampToEdge );
      bitmap.recycle();

      bitmap = BitmapFactory.decodeStream( activity.getAssets().open( "title.png" ) );
      titleTextur = new Textur( gl, bitmap, TexturFilter.Nearest, TexturFilter.Nearest, TexturWrap.ClampToEdge, TexturWrap.ClampToEdge );
      bitmap.recycle();	
   } 
   catch( Exception ex )
   {
      Log.d( "Space Invaders", "couldn't load Texturs" );
      throw new RuntimeException( ex );
   }
	
   soundManager = new SoundManager(activity);
		
   font = new Font( gl, activity.getAssets(), "font.ttf", activity.getViewportWidth() > 480?32:16, FontStyle.Plain );
   text = font.newText( gl );
   text.setText( pressText );
}
... to be continued ...

Wie gewohnt, laden wir hier alle unsere Ressourcen. Zuerst bauen wir das Hintergrund-Mesh, so wie im Renderer. Als nächstes bauen wir das Mesh für das Logo. Dieses definieren wir um den Ursprung in Pixelkoordinaten. Es hat die Abmessungen 512x256 und gemapped auf den oberen Teil folgender Textur:

Datei:title.png

Als nächstes laden wir die Texturen für Hintergrund und Logo. Danach instanzieren wir einen neuen SoundManager. Abschließend erstellen wir uns noch einen Font und eine Text-Instanz, der wir den Aufforderungstext setzen. Interessant hierbei ist, dass ich die Größe des Fonts abhängig von der Viewport Breite mache. Android unterstützt ja mittlerweile mehrere Bildschirmauflösungen. Ein 16-Pixel-Font sieht auf 800x480 zu klein aus, deswegen machen wir im Fall einer hohen Bildschirmauflösung den Font doppelt so groß.

... continued ...
@Override
public boolean isDone() 
{	
   return isDone;
}
... to be continued ...

Die erste Interface Methode ist relativ einfach, sie liefert nur den Inhalt der isDone-Variablen zurück und signalisiert so nach außen, ob der Screen fertig ist oder nicht.

... continued ...
@Override
public void update(GameActivity activity) 
{	
   if( activity.isTouched() )
   isDone = true;
}
... to be continued ...

Auch update hält sich simpel. Wir erfragen lediglich von der GameActivity, ob der Screen aktuell berührt wird und setzen isDone entsprechend. Damit haben wir auch schon die Logik dieses Screens komplett implementiert. Fehlt noch das Rendern:

... continued ...
@Override
public void render(GL10 gl, GameActivity activity) 
{	
   gl.glViewport( 0, 0, activity.getViewportWidth(), activity.getViewportHeight() );
   gl.glClear( GL10.GL_COLOR_BUFFER_BIT );
   gl.glEnable( GL10.GL_TEXTUR_2D );
   gl.glMatrixMode( GL10.GL_PROJECTION );
   gl.glLoadIdentity();
   gl.glMatrixMode( GL10.GL_MODELVIEW );
   gl.glLoadIdentity();
		
   gl.glEnable( GL10.GL_BLEND );
   gl.glBlendFunc( GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA );
	
   backgroundTextur.bind();
   backgroundMesh.render(PrimitiveType.TriangleFan );

   gl.glMatrixMode( GL10.GL_PROJECTION );
   GLU.gluOrtho2D( gl, 0, activity.getViewportWidth(), 0, activity.getViewportHeight() );
   gl.glMatrixMode( GL10.GL_MODELVIEW );
   gl.glLoadIdentity();
	
   gl.glLoadIdentity();
   gl.glTranslatef( activity.getViewportWidth() / 2, activity.getViewportHeight() - 256, 0 );
   titleTextur.bind();
   titleMesh.render(PrimitiveType.TriangleFan);

   gl.glLoadIdentity();
   gl.glTranslatef( activity.getViewportWidth() / 2 - font.getStringWidth( pressText ) / 2, 100, 0 );
   text.render();
	
   gl.glDisable( GL10.GL_TEXTUR_2D );
   gl.glDisable( GL10.GL_BLEND );
}
... to be continued ...

Wir starten wie immer mit dem Setzen des Viewport und dem Löschen des Framebuffers. Als nächstes setzen wir die Projektions- und Model-View-Matrix für das Zeichnen des Hintergrunds. Dasselbe haben wir ja schon im Renderer gemacht. Blending und Texturing schalten wir auch ein. Es folgt das Rendern des Hintergrunds. Danach setzen wir eine orthographische Projektion, damit wir uns wieder im Bildschirmkoordinatensystem befinden. Das Logo zeichnen wir zentriert am oberen Ende des Bildschirms. Den Text zeichnen wir zentriert 100 Pixel über dem unteren Bildschirmrand. Abschließend schalten wir Texturing und Blending wieder aus und sind fertig.

Eine Methode fehlt uns noch:

... continued ...
public void dispose() 
{	
   backgroundTextur.dispose();
   titleTextur.dispose();
   soundManager.dispose();
   font.dispose();
   text.dispose();
   backgroundMesh.dispose();
   titleMesh.dispose();
}

Wie nicht anders zu erwarten, geben wir hier wieder alle Ressourcen frei. Augenmerk soll auch auf das „dispose“ des SoundManagers gelegt werden. Damit drehen wir auch wieder die Musik ab, die sonst weiterlaufen würde!

GameOverScreen-Klasse

Die GameOverScreen Klasse ist nahezu identisch mit der StartScreen Klasse. Einziger Unterschied ist, dass wir anstatt des Logos in großen Lettern Game Over anzeigen und anstatt der Aufforderung zum Berühren des Bildschirms die erreichte Punktezahl anzeigen, die wir über den Konstruktor hereinbekommen. Logik und Rendering sind identisch mit der StartScreen Klasse. Ich erspare mir hier also die Auflistung des Codes.

GameLoop-Klasse

Der interessanteste Screen, ist der GameLoop-Screen. Hier instanzieren wir den Renderer und die Simulation und prozessieren den Input des Accelerometers, um das Schiff zu bewegen. Durch die schöne Kapselung ist diese Klasse extrem klein:

public class GameLoop implements GameScreen, SimulationListener
{
   public Simulation simulation;
   Renderer renderer;	
   SoundManager soundManager;
... to be continued ...
   

Wir haben nur drei Attribute: die Simulation, den Renderer und einen SoundManager. Sehr hübsch!

... continued ...
public GameLoop( GL10 gl, GameActivity activity )
{
   simulation = new Simulation();
   simulation.listener = this;
   renderer = new Renderer( gl, activity );
   soundManager = new SoundManager( activity );
}

public GameLoop(GL10 gl, GameActivity activity, Simulation simulation) 
{
   this.simulation = simulation;
   this.simulation.listener = this;
   renderer = new Renderer( gl, activity );
   soundManager = new SoundManager( activity );
}
... to be continued ... 

Konstruktoren gibt es zwei an der Zahl. Der erste instanziert seine Simulation selbst, der zweite nimmt eine Simulation von außen entgegen. Den Zweiten werden wir später dazu verwenden, ein pausiertes Spiel fortzusetzen. Nach dem Aufruf des Konstruktors haben wir einen voll geladenen Renderer, eine Simulation und einen SoundManager, der bereits die Hintergrundmusik abspielt.

.... continued ...
@Override
public void update(GameActivity activity) 
{	
   processInput( activity );
   simulation.update( activity.getDeltaTime() );
}
... to be continued ...

Die update-Methode des Screens ist wieder denkbar einfach. Zuerst rufen wir die Methode processInput auf, die die Accelerometereingabe in Schiffbewegungen umsetzt. Danach aktualisieren wir einfach die Simulation mit der aktuellen Delta Time.

... continued ...
private void processInput( GameActivity activity )
{		
   if( activity.getAccelerationOnYAxis() < 0 )
      simulation.moveShipLeft( activity.getDeltaTime(), Math.abs(activity.getAccelerationOnYAxis()) / 10 );
   else
      simulation.moveShipRight( activity.getDeltaTime(), Math.abs(activity.getAccelerationOnYAxis()) / 10 );
		
   if( activity.isTouched() )
      simulation.shot();
}
... to be continued ...

In dieser Methode nehmen wir die Benutzereingabe entgegen. Wir erinnern uns noch an die Methoden moveShipLeft/moveShipRight der Simulation. Diese befeuern wir jetzt in Abhängigkeit vom Ausschlag des Accelerometers auf der y-Achse des Gerätes. Wie wir aus dem Rendering Kapitel wissen, liegt dieser im Bereich (-10,10). Durch die Division mit 10 normieren wir diesen auf (-1,1). Dieser Faktor bestimmt dann, wie viel von der Schiffsgeschwindigkeit Ship.SHIP_VELOCITY wirklich herangezogen wird, um das Schiff zu bewegen. Je mehr der Benutzer das Gerät neigt, desto schneller fährt das Schiff nach links und rechts. Das ist alles, was wir brauchen, um das Schiff zu bewegen. Sehr einfach, dank unserer Kapselung.

Auch Schüsse wollen wir abgeben. Dazu fragen wir die GameActivity, ob der Screen berührt wird und rufen in diesem Fall Simulation.shot auf, die für uns das Erstellen eines Schusses, sowie das Prüfen, ob schon ein Schuss des Schiffes im Spielfeld ist, übernimmt. Mehr Code brauchen wir für die Verarbeitung der Benutzereingabe nicht!

... continued ...
public boolean isDone( )
{
   return simulation.ship.lives == 0;
}  
... to be continued ...

Der Screen ist fertig, sobald das Schiff kein Leben mehr hat.

... continued ...
@Override
public void render(GL10 gl, GameActivity activity) 
{	
   renderer.render( gl, activity, simulation);
}
... to be continued ...

Das Rendering übernimmt für uns die Renderer Klasse, der wir einfach die Simulation übergeben.

... continued ...
@Override
public void dispose( )
{
   renderer.dispose();
   soundManager.dispose();
}
... to be continued ...

Und auch das Aufräumen gestaltet sich wieder sehr einfach.

Aufmerksamen Lesern ist vielleicht aufgefallen, dass die Klasse das Interface SimulationListener implementiert. Außerdem setzen wir in den Konstruktoren der Simulation die Instanz der Klasse als Listener. Das nutzen wir aus, um die Soundeffekte abzuspielen!

... continued ...
@Override
public void explosion() 
{
   soundManager.playExplosionSound();
}

@Override
public void shot() 
{	
   soundManager.playShotSound();
}

Jedes Mal, wenn in der Simulation ein Schuss fällt oder eine Explosion erzeugt wird, rufen wir ja den Listener auf. In diesem Fall nutzen wir das um dem SoundManager zu sagen, dass er den entsprechenden Effekt abspielen soll.

Und das war das gesamte Spiel. Es fehlt nur noch die Activity, dann sind wir komplett fertig!

SpaceInvaders Activity

Zum Abschluss müssen wir unsere drei Screens noch irgendwie koordinieren. Auch müssen wir den Activity Life Cycle implementieren. Das machen wir in einer Activity namens SpaceInvaders. Die Activity startet mit dem StartScreen und zeigt diesen so lange an, bis dessen isDone-Methode true zurückgibt. Zu diesem Zeitpunkt schalten wir auf den GameLoop um und das Spiel startet. Das Ganze machen wir dann auch für den GameLoop bzw. den GameOverScreen. Die Activity muss aber natürlich auch auf onPause und onResume regieren. Wir wollen ja, dass das Spiel fortgesetzt werden kann, wenn es durch einen Anruf oder einem Druck auf die Home-Taste unterbrochen wird. Hier hilft uns die schöne Kapselung der Simulation. Schauen wir uns also an, was die Activity alles im Code macht:

public class SpaceInvaders extends GameActivity implements GameListener
{
   GameScreen screen;
   Simulation simulation = null;
	 
   public void onCreate( Bundle bundle )
   {
      setRequestedOrientation(0);
      requestWindowFeature(Window.FEATURE_NO_TITLE);
      getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN );
		 
      super.onCreate( bundle );
      setGameListener( this );		

      if( bundle != null && bundle.containsKey( "simulation" ) )
         simulation = (Simulation)bundle.getSerializable( "simulation" );

      Log.d( "Space Invaders", "created, simulation: " + (simulation != null) );
   } 
... to be continued ...

Die Activity leitet natürlich von GameActivity ab. Des Weiteren implementiert sie das GameListener Interface. Als Member hat sie einen GameScreen und außerdem eine Simulation. Im Konstruktor setzen wir das Fenster der Activity zuerst in den Landscape-Mode, schalten die Titelleiste ab und gehen Fullscreen. Danach rufen wir onCreate der Vaterklasse GameActivity auf, die ja die OpenGL-Initialisierung für uns übernimmt und setzen uns selbst als GameListener.

Abhängig vom Life-Cycle kann der Parameter bundle von uns zuvor gesetzte Instanzen verschiedener Klassen besitzen. Für das implementieren der Resume-Fähigkeit werden wir später in dieses Bundle in die Simulation schreiben, wenn wir uns im GameLoop befinden. Im Konstruktor lesen wir lediglich das Bundle aus und überprüfen, ob eine Simulation darin enthalten ist. Ist das so, speichern wir sie im Attribut simulation der Activity.

... continued ...
@Override
public void onSaveInstanceState( Bundle outState )
{
   super.onSaveInstanceState( outState );
   if( screen instanceof GameLoop )
      outState.putSerializable( "simulation", ((GameLoop)screen).simulation );
   Log.d( "Space Invaders", "saved game state" );
}
... to be continued ...

Diese Methode wird vom Android OS aufgerufen, bevor die Activity über den Jordan geht. Sie erlaubt es uns, Zustände temporär zu speichern und diese dann später in onCreate beim Neuerstellen der Activity zu lesen. Ich habe mir die Freiheit genommen, alle Klassen der Simulation das Serializable Interface implementieren zu lassen. Damit brauchen wir hier lediglich überprüfen, ob gerade der GameLoop aktiviert ist und uns von diesem die Simulation holen, die wir dann in das Bundle unter dem Schlüssel simulation ablegen. Damit hätten wir einen Teil des Life-Cycles fertig.

... continued ...
@Override
public void onPause( )
{
   super.onPause();
   if( screen != null )			
      screen.dispose();
   if( screen instanceof GameLoop )
      simulation = ((GameLoop)screen).simulation;
   Log.d( "Space Invaders", "paused" );
}
	
@Override
public void onResume( )
{
   super.onResume();		
   Log.d( "Space Invaders", "resumed" );
}	 
... to be continued ...

Als nächstes müssen wir onPause und onResume implementieren. In beiden rufen wir zuerst die entsprechende Methode der Superklasse auf, das ist wichtig und darf auf keinen Fall vergessen werden. In onPause geben wir auch den aktuell aktiven Screen frei, wenn dieser bereits gesetzt ist. Dann speichern wir die Simulation des GameLoop in unserem Simulations-Objekt, falls der GameLoop aktiv ist. Wir speichern die Simulation hier, da die Geschichte mit dem Bundle nicht immer anschlägt und die Activity nicht neu erstellt wird. Als nächstes fehlen noch die beiden Methoden des GameListener Interface:

... continued ...
@Override
public void setup(GameActivity activity, GL10 gl) 
{	
   if( simulation != null )
   {
      screen = new GameLoop( gl, activity, simulation );
      simulation = null;
      Log.d( "Space Invaders", "resuming previous game" );
   }
   else
   {
      screen = new StartScreen(gl, activity);
      Log.d( "Space Invaders", "starting a new game" );
   }
}
... to be continued ...  

Diese Methode wird aufgerufen, wenn die GLSurfaceView erstellt wurde. Dementsprechend initialisieren wir hier den Screen, den wir anfangs verwenden wollen. Welchen Screen wir verwenden, machen wir davon abhängig, ob das Attribut simulation gesetzt ist oder nicht. Für den Fall, dass es gesetzt wurde (über das Bundle in onCreate oder in onPause), erstellen wir einen neuen GameLoop, der mit dieser Simulation arbeitet. Andernfalls erstellen wir einen StartScreen, der den User auffordert, den Bildschirm zu berühren. Die Simulation überlebt das Pausieren der Applikation ohne Probleme, lediglich der Renderer muss neu erstellt werden, da beim Pausieren sämtliche Ressourcen, wie Texturs und Meshes, von OpenGL verworfen werden. Kommen wir zum letzten Codeteil in diesem Tutorial:

... continued ...
long start = System.nanoTime();
int frames = 0;	
@Override
public void mainLoopIteration(GameActivity activity, GL10 gl) 
{		 	
   screen.update( activity );
   screen.render( gl, activity);
		 
   if( screen.isDone() )
   {
      screen.dispose();
      Log.d( "Space Invaders", "switching screen: " + screen );
      if( screen instanceof StartScreen )
          screen = new GameLoop( gl, activity );
      else
      if( screen instanceof GameLoop )	
         screen = new GameOverScreen( gl, activity,((GameLoop)screen).simulation.score );
      else
      if( screen instanceof GameOverScreen )
         screen = new StartScreen( gl, activity );			
      Log.d("Space Invaders", "switched to screen: " + screen );
   }

   frames++;
   if( System.nanoTime() - start > 1000000000 )
   {
      Log.d( "Space Invaders", "fps: " + frames );
      frames = 0;
      start = System.nanoTime();
   }
} 

Zuerst wird der aktuelle Screen aktualisiert und gerendert. Als nächstes Fragen wir, ob der Screen fertig ist, damit wir den nächsten Screen aktivieren können. Ist der Screen fertig, geben wir zuerst all seine Ressourcen frei. Als nächstes prüfen wir, welcher Screen aktiv war. Im Falle des Start-Screens, erstellen wir einen neuen GameLoop, im Falle des GameLoop, erzeugen wir einen neuen Game-Over-Screen und im Falle des Game-Over-Screen starten wir den Start-Screen neu. Abschließend erhöhen wir ein Attribut der Klasse Frame-Counter namens frames und prüfen, ob eine Sekunde seit der letzten Zeit-Messung vergangen ist. Ist dies der Fall, geben wir die Anzahl der Frames aus und setzen Startzeit und Frame-Counter auf neue Werte. Damit können wir unsere Frames pro Sekunde messen und ausgeben. Als wirklich allerletzten Punkt müssen wir uns noch anschauen, wie wir die Activity im AndroidManifest.xml definieren:

<activity android:name=".spaceinvaders.SpaceInvaders" android:label="Space Invaders" android:launchMode="singleTask">
   <intent-filter>
      <action android:name="android.intent.action.MAIN" />
      <category android:name="android.intent.category.LAUNCHER" />
   </intent-filter>
</activity>  

Hier sollte es keine großen Überraschungen geben. Am wichtigsten ist der launchMode, den wir auf singleTask setzen. Dies muss für die GLSurfaceView so sein, da es sonst zu ein paar kleineren Problemen kommen kann.

Wir sind somit fertig und haben unser erstes kleines 3D-Android-Spiel geschrieben, komplett mit Sound und allem, was dazugehört.

Performance Tipps

Performance ist ein großer Punkt in Android. Da wir mit einer VM arbeiten, die den Code nur interpretiert, also nicht direkt als CPU-Anweisungen ausführt, müssen wir auf ein paar Dinge achten. Hier eine kleine Auswahl der wichtigsten Tipps:

  • Garbage Collection: der Garbage Collector in Androids Dalvik VM ist ein Biest. Er sorgt dafür, dass nicht mehr benötigte Objekte ihren Speicherbereich freigeben. Dieses Freigeben dauert zwischen 100 und 300 Millisekunden, was in einem Spiel fatal ist. Bemerkbar macht sich das durch ein unschönes Stocken des Spielablaufs. Es gilt also zu verhindern, dauernd neue Objekte anzulegen. Am besten instanziert man bereits vorab alles, was, man braucht und verwertet nicht mehr benötigte Objekte wieder. Damit kann man dem Garbage Collector ein Schnippchen schlagen. In unserem Klon haben wir dieses Ziel weitestgehend erreicht. Lediglich das Instanzieren von Schüssen und Explosionen widerspricht dem ein wenig und führt nach längerer Laufzeit zu einem kleinen Aussetzer.
  • Floating Point vs. Fixed Point: bisher erhältliche Android-Geräte besitzen keine Floating Point Unit (im Gegensatz zum iPhone). Bei der Verwendung von Fließkommazahlen werden diese in Software emuliert, was einiges an Performance kosten kann. In den frühen Neunzigern hatten auch viele Desktop PCs noch keine FPU, was Spieleentwickler meist dazu zwang, auf so genannte Fixed-Point Arithmetik umzusteigen. Dasselbe könnte man auch auf Android tun, ich rate aber davon ab, zumindest, wenn man nicht die NDK verwendet und das ganze in C schreibt. Das Implementieren von Fixed-Point Arithmetik in Java unter Dalvik ist nur minimal schneller als die Verwendung von Fließkommazahlen. Da der Code unleserlicher wird, rate ich nur in äußersten Notfällen zur Umstellung auf Fixed-Point.
  • OpenGL Lighting: Wer das Spiel auf G1 oder Hero Hardware ausprobiert hat, wird feststellen, dass es bei voller Invaderzahl ca. 30fps schafft, bei wenigen Invadern auf bis zu 60fps kommt. Grund dafür ist, dass wir die Beleuchtung eingeschalten haben. In frühen Android-Geräten wird die Lichtberechnung meist von der CPU ausgeführt und ist dementsprechend langsam. Schaltet man diese im Space Invaders Klon ab, bekommt man konstante 60fps. Hier muss man entscheiden, ob die verlorene Performance die ansehnlichere Grafik Wert ist.
  • OpenGL MipMapping: Zeichnet man Objekte mit Texturen, muss die GPU die verwendeten Teile der Texturen auslesen. Je größer dabei die Textur, desto mehr Leseoperationen braucht die Texturierung. Mit Hilfe von MipMapping kann man dies um einiges Verbessern. Beim MipMapping werden von der originalen Textur kleinere Versionen angefertigt. Die GPU prüft bei eingeschaltetem MipMapping dann, wie groß das texturierte Dreieck am Bildschirm ist und wählt eine Textur, die eine entsprechende Textur hat. Ein kleines Objekt bedient sich dabei kleinerer Texturen, was wiederum dazu führt, dass weniger Pixel der Textur gelesen werden müssen. Ich empfehle daher standardmäßig MipMapping für den Minificationsfilter der Texturen zu verwenden. Die Textur-Klasse bietet die entsprechende Funktionalität.

Abschließende Worte

Natürlich konnte ich ihm Rahmen dieses Tutorials nicht alle Aspekte der Spieleentwicklung und Optimierung beschreiben. Spieleentwicklung ist ein Learning-By-Doing-Prozess. Das Lesen von viel Quellcode wird niemandem erspart bleiben, der wirklich gute Spiele schreiben will. Im Netz gibt es zu allen möglichen Themen Unmengen an Material, das sich so auch auf Android anwenden lässt. Abschließend möchte ich daher noch einige Quellen auflisten, die sich der geneigte Leser zu Gemüte führen kann.

Allgemeine Spieleentwicklung

OpenGL ES

Bei Fragen, Anregungen, Korrekturen und Drohungen einfach eine E-Mail an badlogicgames at gmail dot com schreiben. Ich bin sehr antwortfreudig.

Macht's gut!

Anzeige

Bookmark / E-Mail

Klicke auf ein Icon, um diese Wiki-Seite zu bookmarken.
» Diesen Artikel per E-Mail versenden