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 überlicksartig erklären. Anschließend werden die einzelnen Teilaufgaben bei der Entwicklung eines Spiels erklärt. Die so erarbeiteten Themen werden abschließend in eine kleine Space Invaders Variante gegossen.
Als kleine Vorwarnung: Ich schreibe seit ca. 10 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 minimieren. Auch werde ich wo angebracht Anglizismen verwenden da diese das googlen nach weiterführendem Material erleichtern. Auch werde ich einige Wikipedia Links einbauen für Begriffe die man eventuel Nachschlagen möchte. Los geht's.
In diesem Artikel setze ich ein paar Dinge als Minimum vorraus. Keine Angst, höhere Mathematik gehört nicht dazu :)
Den Code zu diesem Tutorial könnt ihr euch per SVN von der Addresse 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 auschecken, in Eclipse importieren und eine Run Configuration anlegen und die Default Launch Activity starten.
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:
Im folgenden wollen wir uns mit diesen 6 Modulen etwas genauer beschäftigen.
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 neuen User Input und simulieren die Welt (für Kartenspiele und Ähnliches muss dies natürlich nicht gelten). So man nicht gerade für eine Konsole programmiert, stellt sich einem jedoch das Problem, dass die meisten Betriebssystem Event-basierte Programmierung als Paradigma gewählt haben. So auch auf Android. Applikationen werden dabei nur bei Bedarf neu gezeichnet, zum Beispiel wenn der User Text eingibt, einen Button drückt und so weiter, beziehungsweise wird Code nur dann ausgeführt wenn es eine User-Eingabe gibt. Im Allgemeinen hebelt man dies aus, indem man einen seperaten 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 das selbe abspielt (stark vereinfacht):
while( !done )
{
processInput( )
simulateWorld( )
renderWorld( )
}
Wie genau dieser Main Loop ausschaut hängt von vielerlei Faktoren ab, zum Beispiel dem verwendeten Betriebssystem, dem Spiel selbst und so weiter.
Im Rahmen dieses Artikels werden wir sehen wir man dieses Konzept äußerst einfach auf Android implementieren kann.
Um dem Spieler die Möglichkeit zu geben in das Spielsystem einzugreifen 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 Betriebssystem-abhängig.
Android verfügt über einige Eingabe Möglichkeiten. Wir werden uns mit den wichtigsten zwei beschäftigen: dem Touch-Screen sowie dem Accelerometer.
Alle Resourcen 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 einem Zip-File fein säuberlich gepackt vorliegen. Das Datei I/O Modul soll dies abstrahieren damit im Programmcode der Zugriff auf die Resourcen erleichtert wird.
Android bietet hier schon einen netten Ansatz mit seinem Resourcen und Asset System auf das wir später noch zu sprechen kommen werden.
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 Resourcen wie Bitmaps und Geometry (auch Meshes genannt) ist die Hauptaufgabe dieses System. Hierunter fallen auch Dinge wie das Zeichnen von Partikel-Effekten oder der Einsatz von sogenannten Shadern (auf Android noch nicht möglich). Ganz allgemein kann festgehalten werden das 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 wage ist: keine Angst der Zusammenhang sollte spätestens beim Entwickeln des Space Invader Clones erkenntlich werden. Es sei jedoch gesagt, dass dieser Ansatz es erlaubt das Grafik Modul beliebig aus zu tauschen, zum Beispiel statt einer 2D Darstellung das ganze auf 3D zu portieren, ohne dass dabei der Simulationsteil geändert werden müsste.
Aufmerksamen Lesern ist vielleicht der Zusammenhang zwischen dem Grafik Modul und dem Main Loop bereits aufgefallen. Neuste Grafikkarten wird meist mit Benchmarks zu Leibe gerückt die die sogenannten 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 zu zeichnen) in einer Sekunde durchlaufen wird. Im Zuge unserer Unternehmung werden wir immer ein Auge auf die Frame Rate werfen um etwaige Bottlenecks 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.
Soundeffekte und Musik gehören zu jedem guten Spiel. Dementsprechend kümmert sich das Sound Modul um das Abspielen solcher Resourcen. Dabei gibt es zwischen Soundeffekten und Musik einen wichtigen Unterschied: Soundeffekte sind in der Regel sehr klein (Kilobyte Bereich) und werden direkt im Hauptspeicher gehalten da sie oft verwendet werden, zum Beispiel das Feuergeräusch einer Kannone. Musik wiederum liegt oft in komprimierter Form vor (mp3, ogg) und braucht unkomprimiert (und damit abspielbar) massig Speicher. Sie wird daher meist gestreamed, das heißt bei Bedarf stückweise von der Festplatte oder einem anderen Medium (DVD,Internet) nachgeladen. Auf Implementationsebene macht dies oft einen Unterschied da dieser Nachlademechanismus meist selbst implementiert werden muss.
In Zeiten von Surround Sound Heimsystemen legen Spieleentwickler auch wert auf dreidimensionalen Klang, meist gleich wie bei der Grafik Hardware-beschleunigt. 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.
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 den anderen an der Partie teilhabenden Rechnern im Netz mit zu teilen. Abhängig vom Genre des Spiels kommen hier verschiedene Methoden zum Einsatz um das Spielgeschehen zu synchronisieren. Dieser Themenbereich ist so groß und komplex das ihm am besten ein eigener Artikel gewidmet werden sollte. Im Rahmen dieses Textes werde ich nicht weiter auf diese Komponente eingehen.
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, wieviel Munition noch über ist und so weiter. Auf Basis der User Eingaben sowie der Entscheidungen einer etwaig 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 Zeit-basiert, 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. Als kleines Beispiel: gegeben einer 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). 10ms/s * 0.016s = 0.16m, das heißt die Kannonenkugel 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 Modules sein. Wie der Name schon sagt ist es egal wieviel 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 bei Verwendung von Physik Systemen man 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 Clone keine grandiosen Physikspielereien implementieren, daher bleiben wir bei der herkömmlichen Zeit-basierten 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.
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 Managen von Dateien über Resourcen und Assets werden wir uns als nächster anschaun. Anschließend werden wir uns näher mit OpenGL ES beschäftigen und Android zwingen 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 Clone gerüstet sind.
Im Zuge dieses Kapitels werden wir wiederverwendbare Komponenten bauen. 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 Clone selbst.
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 schon damit aus zu kennen. Aber keine Angst, das ganze erweist sich als relativ einfach.
Ziel in diesem Kapitel wird es sein eine Lauffähige OpenGL ES Activity zu bauen, die den grundlegenden Activity Life Cycle respektiert. Seit SDK 1.5 gibt es die sogenannten GLSurfaceView. Sie ist ein GUI Baustein ähnlich zum Beispiel einer List View die man einfach in die Activity einhängt und die das initialisieren von OpenGL mit halbwegs guten Parametern für uns übernimmt. 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 paralleln zum Main Loop Konzept. Wir werden dies ausnützen.
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 jedesmal beim Neuzeichnen aufruft. Den Parameter gl den wir dabei erhalten werden wir später genauer Besprechen.
Die Methode onSurfaceCreated wird aufgerufen sobald die GLSurfaceView das fertig initialisiert ist. Hier kann man verschiedene Setup Aufgaben erledigen wie zum Beispiel das laden von Resourcen.
Die Methode onSurfaceChanged wird aufgerufen wenn sich die Abmessungen der GLSurfaceView ändern. Dies passiert wenn der User das Android Device 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 Pixel. Diese Information werden wir später noch benötigen.
Unsere erste Activity hat also ein paar Aufgaben:
Für eine saubere Implementierung werden wir einfach die Klasse Activity ableiten und diese GameActivity nennen. Dieser verpassen wir einen Member 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 zweit weiteren Member Variablen speichern wird die aktuelle Größe des zu bemalenden Bereichs die wir beim Aufruf der Methode onSurfaceCreated in Erfahrung bringen. Diesen Bereich nennt man im übrigen auch Viewport. Wir werden diese Terminologie fortan übernehmen. Wir verpassen der Klasse noch Getter Methoden 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 dieser Rufen wir die selben Methoden auch für unsere GLSurfaceView auf. Dies ist nötig damit diese verschiedene Resourcen sauber verwalten kann.
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 eigenen Member in der Klasse. Die Delta Time selbst errechnen wir dann in der onDrawFrame Methode indem wir einfach die aktuelle Zeit minus der zuvor gespeicherten Zeit nehmen. Diese Delta Time speichern wir in eine weitere Member Variable um später dann im Spiel einfach darauf zugreifen zu können, wir benötigen sie ja für das Frame Independant Movement. Zum Abschluss schreiben wir die aktuelle Zeit wieder in unsere dafür vorgesehene Member Variable für die nächste Delta Time Berechnung im nächsten Frame.
Als letzten Puzzle Stein werden wir noch an unserem Design etwas feilen. Wir wollen unsere Activity ja nicht jedesmal neu schreiben, darum führen wir ein eigenes Listener Konzept ein. Dies machen wir über eine simples Interface welches 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 Resourcen zu laden die wir dann später im Main Loop gebrauchen. In der GameActivity rufen wir diese Methode in onSurfaceCreated auf, so 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 implementieren wir lediglich eine Activity die direkt von GameActivity ableitet und 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 entgegen zu nehmen. Den Source 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.
Das Lesen von User Eingaben 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 müssen 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 hochgegangen ist. 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. Dies ist ein wichtiges Faktum welches vielen Beginnern am Anfang Probleme macht da es nicht mit dem in der Schule gelernten klassischen cartesischen 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 Member variablen, 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 y Koordinate in touchX bzw. touchY und setzen isTouched auf true, bei einem MotionEvent.ACTION_MOVE machen wir das selbe 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 auslesen zu können. Dieses auslesen des aktuellen Status nennt man allgemein auch Polling.
Es sei angemerkt das die onTouch Methode im GUI Thread und nicht im Render Thread der GLSurfaceView vom Betriebssystem aufgerufen wird. Normalerweise müßte man sich hier Sorgen um etwaige Thread Synchronisierung machen. Da es sich bei den Member Variablen 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 schaun 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 Umschweife 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 zuerst den ersten Accelerometer Sensor den wir finden (in der Regel gibt es davon nur einen). Anschließend registrieren wir uns über die SensorManager.registerListener() Methode. Dieser Vorgang kann fehlschlagen darum prüfen wir das auch. 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 verabeiten der Sensor Events. Das machen wir in der SensorEventListener.onSensorChanged() Methode die wir implementieren.
public abstract void onSensorChanged(SensorEvent event)
Ähnlich wie bei Touch-verarbeiten bekommen wir hier wieder ein Event, in diesem Fall vom Typ SensorEvent. Diese Klasse besitzt einen public Member namens values der die für uns relevanten Werte enthält. Derer gibt es drei an der Zahl 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 Devices an. Der maximal Wert beträgt dabei jeweils +-9.81m/s was der Erdbeschleunigung entspricht. Hält man das Android Device im Portrait mode so geht die positive x-Achse nach rechts, die positive y-Achse nach oben und die positive z-Achse gerade aus durch das Device.
Dies bleibt auch so wenn man das Device im Landscape Modus haltet. Wir werden dann bei der Space Invaders Umsetzung sehen wie wir diese Werte ausnutzen können.
Gleich wie für Touch Events spendieren wir der GameActivity einige neue Dinge. Zu aller erst wäre da eine neue Member Variable vom Typ float array. Diese hält unsere drei Accelerometer Werte. Weiters lassen wir die Activity das SensorEventListener Interface implementieren. Zum Abschluss bauen wir noch drei Methoden die uns jeweils den Accelerometer Wert für eine Achse liefern und wir sind fertig. Gleich wie für TouchEvents können wir damit den Accelerometer Status pollen.
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.
Date 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-Snippets sollen illustrieren wie man die einzelnen Möglichkeiten verwenden kann.
Resourcen stellen den von Google gewünschten Weg zur Verwaltung von Dateien dar. Sie werden im Android Projekt in speziel 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 Resourcen gibt es nur Lesezugriff da sie direkt in der Apk-Datei der Applikation abgelegt werden, ähnlich wie Resourcen in normalen Java Jar-Dateien. Einen schönen Überblick bietet folgender Link, wir lassen Resourcen einmal außen vor und wenden uns dem nächsten Kandidaten zu.
Assets werden ebenfalls wie Resourcen 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 Resourcen 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 gewohnt über InputStreams:
InputStream in = activity.getAssets().open( "path/to/resource" );
Wie Resourcen sind auch Assets nur lesbar.
So der Besitzer des Android Devices eine SD-Karte eingelegt hat kann man in der Regel auf dieser schreiben und lesen. Dazu bedarf es im AndroidManifest.xml File 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" );
Jetzt geht's ans Eingemachte. OpenGL ES ist eine Schnittstelle die es uns erlaubt direkt mit der Grafikkarte eines mobilen Devices 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 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 anschaun die allgemein in der Computergrafik gelten.
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 sogenannte 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 wieviele Bits pro Pixel verwendet werden. Herkömmlicherweise sind das bei Desktop Systemen 24- bzw. 32-Bit. Auf mobilen Devices 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.
Pixel müssen natürlich addressiert werden können. Man verwendet dazu ein zweidimensionales Koordinaten-System. Koordinaten in diesem System werden dabei in eine lineare Addresse im Framebuffer umgerechnet 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.
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 wir alle 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:
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 vor kommen: 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.
Zu allerletzt 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.
Wie Eingangs schon erwähnt ist OpenGL im Grunde seines Herzens eine Dreieck-Zeichenmaschine. In diesem Abschnitt wollen wir uns dran 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 sogenannte 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(ByteBuffer.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 bitte 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:
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 Methoden Aufruf 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 Devices 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 Positionensdaten 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, und zwar Dreiecke. Der zweite Parameter gibt an ab welcher Position im FloatBuffer OpenGL beginnen soll die Positionsdaten zu holen. Der letzte Parameter sagt OpenGL noch wieviele 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 Source etwas schöner formartiert 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
Ein weisses 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:
Ich habe vorher schon erwähnt das 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 den selben 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 das es bitte 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 das 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 wieviele 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 wars auch schon wieder. Achtung: hat man den client state GL10.GL_COLOR_ARRAY enabled so wird jeder Aufruf von glColor4f ignoriert. Es muss dann unbedingt ein FloatBuffer mit glColorPointer angegeben werden der zumindest soviele Farben besitzt wie das Mesh Vertices hat, bzw. soviele wie man Vertices bei glDrawArrays angibt!
Der gesamte Code zum Zeichnen unseres nun schön eingefärbten Dreiecks schaut 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, enablen wir einen Array (GL10.GL_VERTEX_ARRAY, GL10.GL_COLOR_ARRAY) mit glEnableClientState und geben dann mit einer der glXXXPointer Methoden an wo der 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 sogenannten 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 die auf allen Android Devices funktionieren.
Hier noch ein Screenshot unseres farbigen Dreiecks:
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.
So richtig peppig wird's 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 schaun wir uns schnell an wie man eine Bitmap überhaupt ladet. Im Beispiel Projekt habe ich im assets Verzeichnis ein PNG-File 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". Nachdem die Methode eine IOException wirft baun wir noch ein try-catch drum. Da wir klug genug waren das Asset auch wirklich in das entsprechende Verzeichnis zu packen sollte keine Exception fallen. Normalerweise handle ich Exceptions beim Resourcen laden 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äd wird in ein normiertes Koordinaten-System gelegt:
Den Achsen geben wir zur Vermeidung von Verwechslungen mit dem Vertex Positions 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 Texture mit einer niedrig auflösenden Texture austauschen ohne die Texture-Koordinaten des Meshes zu ändern. Was die Bildabmessungen betrifft so gibt es eine Limitation auf Android: diese müssen 2er 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 Texture-Koordinate angeben. Die Angabe erfolgt dabei im Koordinaten-System der Texture, also zweidimensional und jeweils zwischen 0 und 1 (man kann auch kleinere und größere Werte angeben, das schaun wir uns aber später an):
Hier haben wir unser Dreieck gemapped. Aufmerksame Leser wissen schon was jetzt kommt: Die Koordinaten stopfen 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 3 Vertices für die wir jeweils Texture-Koordinaten mit 2 Komponenten haben die wiederum jeweils 4 Byte groß sind (float). Der Rest sollte selbst erklärend sein.
Bevor wir die Texture-Koordinaten als Vertex Komponente OpenGL mitteilen müssen wir uns noch um eine Kleinigkeit kümmern: das eigentliche laden der Texture. Wir haben zwar schon die Bitmap aus dem Asset geladen, eine Texture haben wir aber noch nicht erstellt. Das machen wir jetzt:
int[] textureIds = new int[1]; gl.glGenTextures(1, textureIds, 0); textureId = textureIds[0];
Mit glGenTextures weisen wir OpenGL an uns eine neue Texture zu erstellen. Der erste Parameter gibt dabei an wieviele Texturen wir erstellen wollen (eine), in den zweiten Parameter speichert OpenGL dann die ID(s) der neuen Texture(n). Der letzte Parameter ist nur ein Offset ab dem OpenGL in dem übergebenen Array schreiben soll. Die erhaltene Texture-ID müssen wir uns merken, mit dieser aktivieren wir dann später die Texture.
Als nächstes müssen wir die Bitmap in die Texture 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, textureId ); GLUtils.texImage2D( GL10.GL_TEXTURE_2D, 0, bitmap, 0);
Zuerst müssen wir die Texture "binden" damit sie zur aktuell aktiven Texture 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 Texture-ID. Erst dann können wir Daten in die Texture laden, ihre Konfiguration ändern oder sie als Texture 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 Texture läd. Den ersten Parameter ignorieren wir wieder, den zweiten auch (gibt den MipMap-Level an), als dritten übergeben wir die Bitmap und den letzten Parameter ignorieren wir auch wieder. Damit hat unsere Texture jetzt die Bilddaten die sie haben soll.
Als letzten Schritt müssen wir die Texture 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 aktuel gebundenen Texture die zum Einsatz komm wenn die Texture 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äßlichste, 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 Texture-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 das 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 das die Koordinaten modulo 1 genommen werden. Eine 4.5 wird so zur 0.5 und so weiter. Damit kann man die Texture über ein Dreieck mehrere Male wiederholen. Die Angabe des Wrap-Modus erfolgt dabei für die s unt t Komponente einzeln. Man kann also auf s z.B. clampen und auf t wrappen.
Damit haben wir die Texture fertig geladen und konfiguriert. Uns bleibt noch das zeichnen mit der Texture über. 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 das es ab jetzt alle Meshes mit der aktuel gebundenen Texture texturieren soll. Als nächstes binden wir unsere Texture. Dann geben wir an dass unsere Vertices Texture-Koordinaten haben und wir die gleich übergeben was im nächsten Aufruf mit glTexCoordPointer geschieht. Hier unser texturiertes Dreieck:
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 Texture nur einmal baut (z.B. in der setup) Methode. Ich hatte da schon Code von einigen Leuten gesehen die die selbe Texture immer und immer wieder bauen. Wie für Meshes gilt: einmal bauen, solang verwenden wie nötig, dann die Resourcen wieder freigeben. Im Fall von Vertex Arrays gibts 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 Texture-ID an und schon ist die Texture Geschichte. Man sollte ein gelöschte Texture natürlich nach dem Löschen nicht mehr binden.
Und das war's wieder. Eigentlich keine Rocket Science, ein wenig Code ist es aber schon. Wir werden darum zwei Klassen bauen die uns für Meshes und Textures ein wenig Arbeit abnehmen und den Code schlanker machen.
Für euer Seelenheil hab ich zwei Klassen gestrickt 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 wieviele Vertices das Mesh insgesamt haben soll. Der dritte Parameter besagt ob das Mesh auch Colors definiert, der vierte ob Texture-Koordinaten dabei sein sollen und der vierte 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 in den Source miteingebaut.
Nachdem ihr das Mesh instanziert habt könnt ihr es sehr einfach befüllen. Unser Color Sample von oben würde z.B. so ausschaun:
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, Texture-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 das 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 noch 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 was neues mache ich dort nicht. Die Grundlagen dafür habt ihr bereits oben gesehen. Den Source Code zu einem Beispiel Programm welches 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össelt und Vertex Buffer Objects implementiert. Diese werden verwendet wenn das Device diese unterstützt. Sie geben ein wenig mehr Performance. Auch Devices 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 direct Buffer leaken lässt. Das ganze erfolgt transparent ihr müsst euch also nicht darum kümmern. Seit ihr mit der Verwendung eines Mesh fertig müsst ihr dieses per Aufruf der Methode Mesh.dispose() freigeben!
Die Texture Klasse ist noch einfacher:
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, gleich 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 Texture-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 Texture bindet, gleich wie glBindTexture. Die Methode dispose löscht die Texture und gibt alle Resourcen 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 Texture zu zeichnen. Die Koordinaten werden dabei in Pixel angegeben, der Ursprung ist das obere linke Eck, die positive y-Achse geht nach unten. Intern bindet die Methode die Texture vor dem zeichnen, man muss hier also auf den Seiteneffekt achten.
Was die Klasse nicht macht ist das Einschalten von Texturierung über glEnable. Darauf also nicht vergessen. Ein Beispiel für die Verwendung der Texture 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] anschaun. Das Mesh dort verwendet Colors und Texture-Coordinates was einen hübschen Effekt hat :)
Damit haben wir jetzt zwei sehr kleine und feine Klassen die uns viel Arbeit und Code abnehmen.
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 gibts 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 alles Spiele die einen 3D Eindruck verwenden wollen. In der Schule sollten die meisten von euch schon mal Fluchtpunkt Zeichnungen gemacht haben, genau das selbe 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 Koordinate 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:
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 das 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-Matrize. Man stelle sich hier einfach vor, dass der Inhalt der Projektions-Matrize dadurch gelöscht wird und die Matrize keinen Einfluß auf unsere Vertices hat. Die Multiplikation mit dieser Matrize ergibt den selben Vektor. Abschließend verwenden wir glOrtho2D welches eine orthographische Projektions-Matrize läd. 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 ausschaut:
Zur Veranschaulichung hier noch das ganze mit einem Mesh welches 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 welches 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]
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 durchschaun 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).
Dieser Punkt ist insofern besonders als dass er der Position des Auges eines Betrachters in unserer dreidimensionalen Welt entspricht.
Die perspektivische Projektion wird durch mehrere Parameter definiert. Zum einen durch das sogenannte 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, schaun 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 das 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 2 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:
Wie zu erwarten ist das grüne Dreieck kleiner als das rote da es ja auch weiter entfernt ist. Wir haben somit den Schritt in die 3te 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]
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 definiert ist. 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 sogenannte 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 sogenannte Kreuz-Produkt aus Up und Richtungsvektor errechnen. Das brauchen wir aber alles gar nicht da uns das die GLU Klasse abnimmt. Zur 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 änder 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 auf einander stehen, also im neunzig Grad Winkel.
Damit wir unsere Welt aus der Sicht der Kamera sehen müssen wir wieder eine Matrize von OpenGL bemühen. Diese nennt sich die Model-View-Matrize. Der View Teil bezeichnet dabei den Umstand das man in dieser Matrize die Kamera-Matrize (die sich aus den oben genannten Eigenschaften der Kamera ergibt) ablegt. Ein Vertex wird zuerst durch die Model-View-Matrize transformiert (per Multiplikation) und dann mit der Projektions-Matrix multipliziert um seine finale Position zu bestimmen. Schaun wir uns also an wie wir diese Model-View-Matrix mit GLU so setzen können das 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 Matrize 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 vektoriel 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] )
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. Jeder 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 einen Pixel zeichnet prüft es ob im Z-Buffer bereits ein Pixel existiert der näher zur Kamera liegt. Ist dem 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, was so geht:
gl.glClear(GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT);
Wer auch noch die Farbe mit der der Frame Buffer gelöscht werden soll bestimmen will kann dies folgendermaßen tun:
gl.glClearColor( red, green, blue, alpha );
Der Frame und der Z-Buffer sollte möglichst in jedem Frame gelöscht werden. Normalerweise mache ich das immer ganz am Anfang des Rendering damit ich nicht darauf vergesse. Schaun wir uns an wie unser Dreieck-Sortierproblem jetzt ausschaut:
Ausgezeichnet, und all das mit nur zwei zusätzlichen Befehlen. Mit dem Z-Buffer kommen aber auch Probleme:
Beachtet man diese beiden Probleme steht dem vergnüglichen Gebrauch des Z-Buffers nichts im Weg!
3D allein 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 & Schatten.
OpenGL bietet hier einiges an Möglichkeiten zumindest was Licht betrifft. Schattenwurf wie wir in kennen ist nicht direkt in OpenGL inkludiert kann aber nachgebaut werden. Mit OpenGL ES ist dies jedoch zu rechenaufwendig 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 sondern wollen deren vollen Farben haben. Darum müssen wir vor dem Zeichnen der 2D Elemente 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 und wird aufgrund von feinen unebenheiten des das Licht reflektierenden Objekts in alle möglichen Richtungen reflektiert. Spekulares Licht hingegen wird scharf reflektiert, z.B. auf einem Spiegel und bildet an einem bestimmten Punkt am Objekt ein sogenanntes 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.
Wir werden uns nur mit ambienten und diffusen Licht in OpenGL beschäftigen. Spekulares Licht benötigt ein sehr fein aufgelöstest 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 emitiert 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 emitiert. 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 der Lichtfarbe, schönes weiss. 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 4 Elementen. Ist das letzte Element gleich 0 so sagt dies 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! Man kann sich dies auch als die Position einer Lichtquelle gelten. Die Richtung ist dann der Vektor von der Lichtquelle zum Ursprung. Sehr verwirrent. Ist das vierte Element gleich 1 so sagen wir OpenGL damit das 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 );
Eine Punktlichtquelle direkt über dem Ursprung gibt man so an:
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 elaborierten Mechanismus um das Material eines Objekts zu definieren. So elaboriert dieser ist 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 weißt OpenGL ES an das 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 & Texture Klasse erinnern kann denkt vielleicht jetzt an die Normalen die wir pro Vertex gleich wie Farbe oder Texture-Koordinaten angeben kann. Diese brauchen wir auch unbedingt wenn wir OpenGLs Beleuchtungsmodel verwenden wollen. Was ist also so eine Vertex-Normale? Ein kleines Bild:
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 die in Meshes die wir beleuchten wollen auch unbedingt angeben. Im Source zur Mesh Klasse könnt ihr euch anschaun wie man die Normale eines Vertex OpenGL übergibt. Der Mechanismus ist 1:1 der selbe wie bei Farben und Texture-Koordinaten, darum gehe ich hier nicht gesondert nochmal 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 das 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 3 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 enablen. Als Lichtquelle nehmen wir eine weisses 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 auf zu rä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. So sich diese über den Verlauf nicht ändert reicht es deren Lichttypen 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. Verwirrent. 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:
Eine Punktlichtquelle würde komplett analog dazu definiert und eingesetzt werden, mit dem Unterschied im 4ten Element im Positions Array. Und wieder ein Geheimnis von OpenGL gelüftet!
Jetzt wird's noch mal kurz mathematisch. Vielleicht hat sich der eine oder andere bereits gefragt wie man den 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 der selbe Einheitentyp mehrere male gezeichnet wird nur an verschiedenen Positionen und unterschiedlicher Ausrichtung. In OpenGL verwendet man dazu wieder Matrizen, genauer, die bereits erwähnte Model-View-Matrize. Hier erklärt sich auch der erste Teil des Namens der Matrize: 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:
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 3 Parametern um den Translations-Vektor. Diese Methode erstellt intern eine Translations Matrix und multipliziert die aktuel 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:
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 aktuel aktiven Matrix multipliziert.
Die Rotation ist ein wenig schwieriger 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.
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, verschieben, skalieren usw. Als aktive Matrix wählen wir für diese Transformationen immer die Model-View-Matrix über glMatrixMode. Schaun wir uns einmal an was die Kombination von Translation und Rotation bewirkt:
Zuerst verschieben wir das Dreieck ein wenig nach rechts, dann rotieren wir um die positive z-Achse. Wenn wir das ganze umdrehen schaut das ergebnis so aus:
Ein komplett anderes Ergebnis. Wir müssen bei der Anwedung von Transformationen immer auf die Reihenfolge schaun. Die 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 verschiegen 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 jedesmal neue 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 den Top of Stack. Die beiden Methoden glPushMatrix() und glPopMatrix() erlauben es uns die über glMatrixMode aktuel 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 die selbe.
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äd man zu aller erst 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 am Stack haben, multiplizieren dann die Transformationen auf die Model-View-Matrix, zeichnen das Mesh des Objekts, welches somit richtig transformiert wird und popen die Model-View-Matrix wieder vom Stack womit diese wieder nur die Kamera-Matrix beinhaltet. Diesen Prozess wiederholen wir für alle Objekte die wir zeichnen. So werden wir das dann auch in unserem Space Invaders Clone machen. Ein Sample welches 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 eine Applikation Fullscreen macht und die Orientierung fixiert. Das ganze sieht so aus:
Damit haben wir den letzten großen Brocken was OpenGL betrifft abgearbeitet. Wie für Licht gilt: experimentieren, experimentieren, experimentieren. Um Transformationen zu verstehen muß 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.
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 Texture zu zeichnen und uns zu merken wo in der Texture wir die Bitmap für einen Character finden. Wollen wir Text zeichnen müssen wir lediglich ein Mesh erstellen welches für jeden Character im String zwei Dreiecke besitzt die ein Viereck bilden welches gleich groß ist wie die Bitmap für den Character. Weiters mappen wir diese beiden Dreiecke so mit der Character Texture das diese genau den Ausschnitt der Texture verwenden wo die Bitmap des Characters hingezeichnet wurde. Übrigens nennt man die Bitmap für so einen Character auch Glyph. Die Texture ist demzurfolge ein sogenannter 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 einem Asset file. Dazu geben wir den AssetManager an den wir von unserer Activity erhalten sowie den Filenamen 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 Resourcen (Glyphcache Texture) wieder frei.
Die Klasse Text hat einige Methoden zum formatieren von Text die wir aber für unseren Space Invaders Clone nicht brauchen. Hier die relevantesten 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 Texture 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 und so weiter. Ein wenig damit herumspielen und man hat den Dreh herausen.
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 Texture mit einem Alphawert kleiner als 1 wird durchsichtig. Das selbe 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. 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].
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 Loader 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 Loader laden. Der Loader 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 ein fix und fertiges Mesh zurück. So das Mesh Normalen oder Texture-Koordinaten hat werden diese natürlich mitgeladen und können dann mit einer Lichtquelle bzw. Texture verwendet werden. Ich habe in Wings3D ein kleines Raumschiff gebaut und mit Gimp dafür eine Texture erstellt. Das Obj-File und die Texture 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] anschaun könnt. Es ist im Grunde das Light Sample mit dem Unterschied dass ich eine Texture und das Mesh aus dem Obj-File lade. Außerdem habe ich mir erlaubt hier die Aufgabe aus dem Transformations Kapitel umzusetzen. Das Schiff dreht sich hübsch. Hier noch ein Screenshot
Jetzt haben wir aber wirklich alles besprochen was es zu besprechen gibt. Auf zum Sound!
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 der selbe Soundeffekt mehrere male gleichzeitig abzuspielen sein. Genau diese Aufgaben erledigt für uns der SoundPool. Schaun wir uns an wie man ihn instanziert:
SoundPool soundPool = new SoundPool( 5, AudioManager.STREAM_MUSIC, 0);
Sehr einfach. Der erste Parameter gibt an wieviele Soundeffekte der Soundpool maximal gleichzeitig abspielen kann. Hier geht es wirklich nur um das abspielen, laden können wir soviele 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 gleich. Der letzte Parameter hat zur Zeit 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 ein Audio-File namens "shot.wav" in unserem Asset Verzeichnis haben. Laden tun wir diesen dann wie folgt:
AssetFileDescriptor descriptor = getAssets().openFd( "shot.wav" ); int soundId = soundPool.load( descriptor, 1 );
Als erstes benötigen wir einen AssetFileDescriptor den wir uns für das Audio-File in der erste Zeile holen. Diesen übergeben wir dann in der zweiten Zeile an die Methode SoundPool.load die uns dann das Audio-File in den Speicher läd. Als Rückgabewert erhalten wir für das gerade geladene File 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 seperaten 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 meist 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 zur Zeit 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 es aber im Grunde nicht. Schaun 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 unser Musik Asset. In der nächsten Zeile setzen wir den MediaPlayer dann von diesem File in Kenntnis. In der dritten Zeile geben wir dem MediaPlayer Zeit sich auf das Abspielen vorzubereitn. 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 in 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 welches 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 damit 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!
Wer Space Invaders nicht kennt soll sich zuerst einmal selbst Ohrfeigen. Neben Asteroids war Space Invaders das erste Shot em' up welches kommerziel ä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 Clone des originals kann man unter [http://www.spaceinvaders.de/ http://www.spaceinvaders.de/] Spielen, was ich hiermit jedem empfehle. Hier noch ein kleiner Screenshot:
Bevor wir uns an die Umsetzung des Spiels machen wollen wir seine einzelnen Teile analysieren damit wir eine genaue Vorstellung davon haben was wir alles überhaupt implementieren müssen.
Space Invaders hat ein auf den Bildschirm begrenztes Spielfeld. Es gibt 4 Arten von Invaders, die drei die oben im Screenshot sichtbar sind sowie ein Ufo welches von Zeit zu Zeit am oberen Bildschirmrand vorbeifliegt. Die Invader sind dabei in einem Netz in gleichen Abständen angeordnet. Jede Reihe besteht aus 11 Invadern, insgesamt gibt es 5 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 welche 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 so verliert dieses eines von seinen insgesamt drei Leben. Das Schiff selbst kann immer nur einen Schuss abfeuern. Der nächste kann erst abgefeuert werden wenn der erste verschwunden ist. Dies geschieht wenn ein Invader getroffen wird oder der Schuss das Spielfeld verliert. 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, gleich wie ein getroffener Invader und kann für einen Sekunde nicht kontrolliert werden. Es respawned an der letzten Position. Die Kontrolle des Schiffes erlaubt das Steuern nach links und rechts sowie das Abfeuern eines Schusses so noch kein Schuss des Schiffes am Spielfeld ist.
Aus all dem gesagten lassen sich die Elemente für das Spiel relativ einfach ableiten:
Wir werden uns als nächstes anschaun wie wir unseren Clone strukturieren. Die hier besprochenen Elemente werden in unseren Clone einfließen, ein paar Dinge werden wir adaptieren (müssen).
Unser Space Invader Clone 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 dreidimenionalen 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.
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 weitgehenst 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 5 Reihen zu je 11 Invadern werden wir 4 Reihen zu je 8 Invadern haben. Die Invader haben einen Durchmesser von 1, also werden wir sie im Abstand von 2 Einheiten neben und untereinander positionieren. Die Invader der obersten Reihe haben also z-Werte von -15, die der nächsten Reihe -13 usw. Der linkeste Invader einer Reihe sitzt auf x=-7, der nächste auf -5 usw. Das ganze sieht dann initial so aus:
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 5 Subblöcken eine U-Form. Jeder Subblock hat dabei die größe 1x1. Anstatt 4 Blöcken wie im Original werden wir nur 3 Blöcke bauen. 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 5 Subblöcke bauen wir um die Zentren. Das ganze sieht dann wie folgt aus:
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 des ganzen widmen.
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 bauen wir auch noch eine Simulationsklasse die all die Elemente beherbergt und für den eigentlich Spielablauf sorgt, d.h. dafür sorgt dass sich die Invader bewegen, Schüsse ihr Ziel treffen und so weiter. Die Simulation wird dabei im im letzten Kapitel beschriebenen Koordinaten-System ablaufen, also in der x-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 Programmes 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 vektoriel 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] anschaun. Am wichtigsten dabei wird für uns die Methode zum messen der Distanz zwischen zwei Punkten (hier fälschlich auch Vektoren gennant) 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 Member 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 speziel in Spielen nicht haben. Getter und Setter Methoden sind in diesem Rahmen meist Overkill und sollten vermieden werden. In unserem simplen Space Invader Clone 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 bewußt nehmen ihn aber aus Gründen der Performance in kauf.
Fangen wir mit der Klasse für Blöcke an.
Springen wir gleich in 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 Member speichern (position). Zusätzlich haben wir eine statische finale Member Variable die den Radius eines Blocks definiert, in diesem Fall 0.5f Einheiten. Diese Art von Konstanten werden 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 Rocket Science: es wird lediglich die Position des Blocks gesetzt. Mehr tut 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.
Mit der Explosion Klasse modelieren 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 schieß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 das sie ihren Status entsprechend der vergangenen Zeit anpassen. Das kann das Zeit-basierte ä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 einen statischen finalen Member der Klasse und setzen diesen auf 1 für eine Sekunde. Weiters besitzt jede Instanz der Klasse einen Member zur Speicherung ihrer bereits abgelaufenen Lebensdauer sowie ihrer Position. Zweitere wird einmal im Konstruktor gesetzt. Zum Konstruktor geselt sich eine weiter Methode, die bereits besprochene update Methode. Diese bekommt die Delta Time übergeben welche sie auf den Lebensdauer Member aufaddiert. Diesen Member 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 simpel, wenden wir uns also einer etwas weniger simplen Klasse zu.
Wie der Name besagt simuliert diese Klasse einen Schuss in unserem Spiel. Ein Schuss definiert sich wieder über eine Position im Raum. Auch 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 muß 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 Member durch. Die statische Konstante SHOT_VELOCITY definiert die Geschwindigkeit in Einheiten pro Sekunde mit der ein Schuss fliegt. Übersetzt bedeutet der Wert: in einer Sekunde fliegt der Schuss 10 Einheiten weit. Weiters besitzt die Klasse einen Member für die Position, ein Flag ob der Schuss vom Schiff stammt oder von einem Invader sowie ein Flag ob der Schuss das Spielfeld verlassen hat. Der Konstruktor bietet wieder keine Überraschungen und setzt lediglich zwei Member. Schaun wir uns also die update Methode genauer an.
Als erster ä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 mal der vergangenen Zeit. Die Geschwindigkeit haben wir als Konstante definiert (SHOT_VELOCITY) die Zeit bekommen wir als Parameter und 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 das er von einem Schiff/Invader abgefeuert wurde und somit valide 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 Member die die maximale und minimale z-Koordinate des Spielfeldes angeben. Ergibt die Prüfung dass der Schuss nicht mehr im Spielfeld ist setzen wir den Member hasLeftField auf true. Die Simulation wird diesen Wert später, gleich wie die Lebensdauer der Explosion, dazu verwenden zu bewerten ob der Schuss aus der Simulation entfernt werden kann oder nicht.
Das wichtigste an dieser Klasse ist das Zeit-basierte Update der Position. Diese Prinzip müsst ihr euch verinnerlichen, es wird uns noch ein paar mal begegnen.
Und das Muster setzt sich fort. Auch unser Schiff braucht natürlich eine Position. Gleich wie Blöcke hat es auch einen Radius und eine Höchstgeschwindigkeit. Zusätzlich kann sich ein Schiff auch gerade in Luft auflösen, sprich explodieren. Dies müssen wir irgendwie vermerken da in dieser Zeit das Schiff ja nicht beschossen werden kann. Auch hat ein Schiff eine Anzahl an Leben (3 als Standard). Schaun 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 weiterer Member hält fest wieviele Leben das Schiff noch besitzt. Das Flag isExploding speichert ob das Schiff gerade explodiert, der Member 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). Schaun wir uns die update Methode an.
Wieder bekommen wir als Parameter die Delta Time. Explodiert das Schiff rechnen wir diese einfach auf den Member explodeTime auf. Ist das Schiff lange genug explodiert setzen wir das Flag isExploding sowie den Member explodeTime wieder zurück. Das Schiff befindet sich danach wieder im normal Zustand.
Aufmerksame Leser werden bemerken dass das Schiff nicht bewegt wird. Das machen wir dann später in der Simulation auf Basis des User Input. Auch das Abziehen von Leben im Fall einer Kollision mit einem Schuss oder Invader wird in der Simulation erledigt.
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. Schaun wir uns nochmal das Bild dazu an:
Anfangs legt der Invader 6 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 simpel. Wir merken uns für den aktuellen state (links, rechts, runter) wie weit der Invader schon gewandert ist. Hat er die maximale Distanz für den State erreicht (13 oder 1) wechseln wir in den nächsten State. Den State selbst müssen wir uns natürlich auch merken.
Die Bewegung des Invaders erfolgt natürlich wieder Zeit-basiert über die Multiplikation der Geschwindigkeit mit der Delta Time. Das Ergebnis rechnen wir dann entsprechend dem State 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 States in der sich ein Invader befinden kann. Natürlich haben wir auch wieder einen Member für die Position. Ein weiterer Member 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 State nach links oder nach rechts geführt hat. Der letzte Member speichert die gefahrene Distanz für den aktuellen State. den Initialisieren wir auf die Hälfte des maximalen x-Wertes der Simulation (siehe Grafik). Den Konstruktor kennen wir in der Form auch schon. Schaun wir uns also die update Methode an.
Was als erster auffällt ist ein zweiter Parameter namens speedMultiplier. Unser Clone 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 das wir diesen Wert bei der Zeit-basierten bewegung aufmultiplizieren müssen.
Als erster addieren wir in dieser Methode die im letzten Frame zurückgelegt Strecke auf movedDistance. Keine große Sache, wir berechnen einfach den Zeit-basierten Weg (mal Speedmultiplier). Als nächstes Checken wir in welchem State wir uns befinden.
Sind wir im STATE_MOVE_LEFT State bewegen wir unseren Invader Zeit-basiert ein Stück nach links (Geschwindigkeit * Delta Time * Speedmultiplier = Im Frame zurückgelegte Strecke). Danach checken wir ob wir die maximale Distanz für diesen State zurückgelegt haben. Ist dem der Fall wechseln wir auf den STATE_MOVE_DOWN State und setzen movedDistance auf 0. Auch merken wir uns das der letzte horizontale State nach links ging.
Das selbe Spiel spielen wir wenn wir uns im STATE_MOVE_RIGHT State befinden. Anstatt nach links bewegen wir uns nach rechts. Sollte die maximale Distanz für den State überschritten sein setzen wir den State wieder auf STATE_MOVE_DOWN, movedDistance auf 0 und merken uns dass wir nach rechts gefahren sind.
Das Handling des States STATE_MOVE_DOWN läuft ein wenig anders ab. Zuerst bewegen wir uns einmal Zeit-basiert nach unten. Haben wir die maximale Distanz für den State überschritten (>1) wechseln wir in einen der horizontalen States. Fuhren wir zuvor nach links müssen wir jetzt nach rechts fahren und vice versa. 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.
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 auf updated werden. Zum anderen checkt sie verschiedene Ereignisse wie die Kollision von Schüssen und führt entsprechende Reaktionen aus, wie 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 und für die Bewegung des Schiffes sowie das Feuern eines Schusses verantwortlich sind. Wir werden die Klasse in kleinen Stückchen sezieren um ihre Funktionsweise zu verstehen. Beginnen wir mit den Membern:
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 4 Member sind wieder Konstanten für unser Spielfeld, die ersten beiden geben die Begrenzung auf der x-Achse an, die anderen beiden die Begrenzung auf der z-Achse.
Es folgen die Listen für die verschiedenen Spielelemente. Wieder keine große Überraschung, wir verwenden einfach ArrayLists für Invader, Blocks, Shots 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 seperat nochmal 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 Soundeffekt 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, d.h. Invader werden mit der Geschwindigkeit Invader.INVADER_VELOCITY fliegen. Später werden wir diesen Multiplier erhöhen damit die Invader schneller werden.
Natürlich speichern wir auch die aktuelle Score, ohne die wäre das Spiel nur halb so lustig. Desweiteren speichern wir noch wieviele Waves an Invadern bereits aufgetreten sind. Reine Statistik ohne große Funktion.
Die letzten beiden ArrayList ist eine Utility Member den 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 fertiges Spielfeld darin vorfinden indem alle Elemente so positioniert sind wie im Abschnitt Spielfeld dargelegt. Das befüllen der Simulation werden wir in eine eigene Methode namens populate packen. Schaun 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 welches das Befüllen der Simulation übernimmt. In der Methode populate beginnen wir damit das Schiff zu instanzieren. Als nächster platzieren wir die Invader auf dem Spielfeld wie vorher schon beschrieben. Die erste Schleife geht dabei über die 4 Reihen, die nächste über die jeweiles 8 Invader pro Reihe. Die neu erstellten Invader geben wir in unseren Member invaders damit wir 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 Zeit-basierte updaten aller Spielelemente. Dazu haben wir wie gehabt eine update Methode die von außen die Delta Time reinbekommt. Schaun 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 erster updaten wir das Schiff. Wir erinnern uns dass im Update im Fall einer Explosion deren Zeit gemessen wird und ein entsprechendes Flag gesetzt wird. Anschließend updaten wir die Invader, die Schüsse und die Explosionen. Wir sehen uns die drei Methoden gleich im Detail an. Als nächster schaun wir ob es Kollisionen zwischen dem Schiff und Schüssen bzw. Invadern gab (checkShipCollision). Das selbe tun wir dann auch für die Invader (checkInvaderCollision) und für die Blöcke (checkBlockCollision). Zu guter letzt schaun wir ob alle Invader der aktuellen Welle zerstört wurden und befüllen 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 auseinander setzen. Gehen wir sie der Reihe 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. Wir werden uns das verinnerlichen.
Als nächster schaun 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 checked ob der Schuss außerhalb des Spielfelds ist und bewegt den Schuss. In der Schleife schaun wir nach dem Update ob der Schuss das Spielfeld verlassen hat indem wir das entsprechende Flag checken 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 mit etwas umständlich mit dieser removedShots Liste machen.
Als nächstes schaun 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 das 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 schwieriger zu verstehen. Die Invader sollen ja ebenfalls hin und wieder passieren. 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 * multiplier 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 randomisiert einen der Invader aus und erzeugen an dessen Position einen neuen Schuss den wir in die shots Liste hinzufü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 schonmal einen essentiellen Mechanismus, nämlich das Schießen der Invader abgehackt. Die Schüsse die wir hier neu hinzufügen werden im nächsten Update über die vorhergehende Schleife wieder verarbeitet.
Als nächster schaun wir uns an was wir mit den Explosions 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 updaten 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 gegenzuprüfen. Befindet sich der Schuss innerhalb des Radius eines Invaders so geht der in einer Explosion hoch.
Als erster schaun wir ob es überhaupt einen Schiffschuss gibt. Ist dem 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 den auf und teilen ihm mit dass eine Explosion stattgefunden hat. Zu guter letzt erhöhen wir den Score und brechen die Schleife ab.
Es sei hier angemerkt das 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 am nächsten getroffenen merken. 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 ein Invaderschuss das Schiff getroffen hat. Das ganze tun wir aber nur wenn das Schiff nicht explodiert. Würde 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 das es bitte explodieren soll indem wir das entsprechende Flag setzen. Der Listener wird aufgerufen und die Schleife abgebrochen. Mehr als einmal kann das Schiff nicht getroffen werden.
Als nächster prüfen wir ob ein Invader mit dem Schiff kollidiert ist. Hier machen wir das gleiche wie bei den Shots, Distanz checken, 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.
Als letzer müssen wir noch die Blöcke auf Kollisionen mit Schüssen checken:
... 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 checken jeden Schuss mit jedem Block. Wurde ein Block getroffen löschen wir sowohl Block als auch Schuss. Explosion gibt es in diesem Fall keine.
Schaun 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 sowie 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 10 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 anschaun 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 zweiterem auf sich hat erfahren wir gleich. Zuerst checken wir aber einmal ob das Schiff explodiert. Ist dem der Fall brauchen wir erst gar nichts zu tun, explodierende Schiffe bewegen sich nicht mehr.
Haben wir den Check überlebt wird das Schiff 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 letzter checken wir ob das Schiff das Spielfeld verlassen hat. Ist dem der Fall setzen wir es auf den äußersten 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 nach rechts 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 checken wir ob es bereits einen Schiffschuss gibt bzw. das Schiff explodiert. Ist dem nicht der Fall bauen wir einen neuen Schuss an der aktuellen Schiffsposition, geben ihn in die Liste shots und signalisieren dem Listener das ein Schuss abgefeuert wurde. Das wars 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.
Nachdem wir die Simulation so hübsch gekapselt haben wollen wir jetzt das ganze auch auf den Bildschirm zaubern. Dazu brauchen wir für jedes Element im Spiel verschiedene Resourcen, d.h. Meshes und Texturen. Bevor wir uns Code anschaun widmen wir uns kurz der Erstellung der Resourcen.
Am Spielfeld gibt es 4 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 Clone 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 Model 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:
Wie zu erkennen 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 welche 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, an Dankeschön an dieser Stelle.
Für hübsche Explosionen habe ich einen frei im Netz erhältlichen Generator verwendet. Das Ergebnis sieht so aus
Man sieht mehrere Animationsphasen der Explosion. Wir werden später sehen wie wir diese auf ein Mesh klatschen und die Explosionen damit zeichnen.
Als Font zur Darstellung der Score, der Leben und der aktuellen Welle hab ich mir aus dem Internet einen frei verwendbaren 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 Resourcen bewaffnet wenden wir uns jetzt dem Renderer zu.
Ziel der Klasse ist es erstens alle benötigten Resourcen zu laden und zu verwalten und zweitens die Simulation mit diesen Resourcen 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 Resourcen 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 den 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 Devices neigen. Als kleiner Motivator hier ein Bild der fertigen Szene:
Schaun wir uns zuerst die Member der Klasse an:
public class Renderer
{
Mesh shipMesh;
Texture shipTexture;
Mesh invaderMesh;
Texture invaderTexture;
Mesh blockMesh;
Mesh shotMesh;
Mesh backgroundMesh;
Texture backgroundTexture;
Mesh explosionMesh;
Texture explosionTexture;
Font font;
Text text;
float invaderAngle = 0;
int lastScore = 0;
int lastLives = 0;
int lastWave = 0;
... to be continued ...
Für das Schiff und Invader haben wir jeweils ein Mesh sowie eine Texture. 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 Texture, 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 Member merken sich die letzten Werte für Leben, Wave und Punktestand. Die verwenden wir später um Änderungen dieser Werte zu registrieren und nur bei Änderungen bauen wir die Text Klasse neu. Dem Garbage Collector passt das besser.
Bevor wir irgendetwas zeichnen müssen wir zuerst einmal all unsere Resourcen 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" ) );
shipTexture = new Texture( gl, bitmap, TextureFilter.MipMap, TextureFilter.Nearest, TextureWrap.ClampToEdge, TextureWrap.ClampToEdge );
bitmap.recycle();
bitmap = BitmapFactory.decodeStream( activity.getAssets().open( "invader.png" ));
invaderTexture = new Texture( gl, bitmap, TextureFilter.MipMap, TextureFilter.Nearest, TextureWrap.ClampToEdge, TextureWrap.ClampToEdge );
bitmap.recycle();
bitmap = BitmapFactory.decodeStream( activity.getAssets().open( "planet.jpg" ) );
backgroundTexture = new Texture( gl, bitmap, TextureFilter.Nearest, TextureFilter.Nearest, TextureWrap.ClampToEdge, TextureWrap.ClampToEdge );
bitmap.recycle();
bitmap = BitmapFactory.decodeStream( activity.getAssets().open( "explode.png" ) );
explosionTexture = new Texture( gl, bitmap, TextureFilter.MipMap, TextureFilter.Nearest, TextureWrap.ClampToEdge, TextureWrap.ClampToEdge );
bitmap.recycle();
}
catch( Exception ex )
{
Log.d( "Space Invaders", "couldn't load textures" );
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 auf die ganze Hintergrundtexture 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 die selben Abmessungen. 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 wirds wieder ein wenig schwieriger. In der Explosions-Texture 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 funktioniert ähnlich wie in einem Trickfilm. Mehr dazu später.
Nachdem wir jetzt alle Meshes geladen bzw. gebaut 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 jedesmal neu setzen, OpenGL merkt sich das für uns.
Nachdem jetzt alles geladen ist können wir uns gleich das Rendering selbst anschaun. Dazu besitzt der Renderer die Methode render welche eine GL10 Instanz, die GameActivity und die Instanz der Simulation entgegen nimmt. 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_TEXTURE_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_TEXTURE_2D );
renderBlocks( gl, simulation.blocks );
gl.glDisable( GL10.GL_LIGHTING );
renderShots( gl, simulation.shots );
gl.glEnable( GL10.GL_TEXTURE_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_TEXTURE_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 anschaun.
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 sogenanntes Backface Culling dazu. Dieses sorgt dafür das 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 anschaun 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 die ja keine Texturen besitzen. 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 letzter in der Reihenfolge weil diese ja 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 ja 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 dem der Fall so sagen wir der Text Instanz das sie bitte 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 den Member invaderAngle noch zeit-basiert. 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 aber eigentlich nur daraus resultieren dass das Texturing nicht eingeschalten ist. Selbiges gilt auch für die Matrizen welche dafür sorgen können das 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.
Schaun 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();
backgroundTexture.bind();
backgroundMesh.render(PrimitiveType.TriangleFan);
}
... to be continued ...
Beim Rendering des Backgrounds 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 die selben Abmessungen. Wir brauchen daher nur mehr die Hintergrund-Texture 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 zu 100% 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 welches ein direktionales ist. Den Array der die Richtung hält instanzieren wir dabei nicht jedesmal 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;
shipTexture.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 Texture 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 das 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 gebaut 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 ein wenig Eyecandy. Abhängig wie das Gerät entlang seiner y-Achse geneigt ist rotieren wir das Schiff um die z-Achse. Hält man das Device 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 in durch 5 womit wir in einen Wertebereich von (-2,2) kommen. Diesen 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 maximal Werte werden erreich 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 )
{
invaderTexture.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-Texture. 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 ja zeit-basiert.
... 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 Texture haben wir für die Blöcke keine, Texturing ist zu diesem Zeitpunkt schon disabled. 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ück setzen. Langsam wirds 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 );
explosionTexture.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 welches wir gleich einmal einschalten. Dann binden wir die Explosions-Texture und gehen über jede Explosion. Hier wirds interessant. Wie gehabt pushen wir zuerst und transformieren. Beim rendern des Meshes wenden wir aber einen kleinen Trick an. Abhängig davon wie lang die Explosion schon am leben ist Zeichnen wir nur eines der Rechtecke im Explosions-Mesh. Wir erinnern uns das 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 4 Vertices zum Zeichnen der aktuellen Explosion. Der Offset ergibt sich aus der Lebensdauer der Explosion 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 4 da wir den 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( )
{
shipTexture.dispose();
invaderTexture.dispose();
backgroundTexture.dispose();
explosionTexture.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 updaten 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.
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 anschaun werden.
Die Soundeffekte für den Space Invaders Clone habe ich mit einem netten Tool namens sfxr. Dieses erlaubt das simple 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.
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 Member halten wir uns einen SoundPool, den AudioManager einen MediaPlayer sowie die später initialisierten ids der beiden Soundeffekte. Im Konstruktor bauen wir zuerst einen SoundPool der 10 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 Membern.
Nach dem Instanzieren des MediaPlayers lassen wir diesen das Musik-File abspielen und zwar geloopt.
Natürlich gibts 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.
Endlich können wir uns dem letzten Baustein unserers Spiels widmen, der Activity und den sogenannten Screens. Ein Screen stellt einen Zustand im Spiel dar, in unserem Fall ist das der Start-Bildschirm der das Spiellogo zeigt sowie 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 Stati des Spiels in der eigentlich Activity zu managen. 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. des 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 Resourcen des alten wieder freigegeben werden (SoundManager, Renderer).
Wie bereits erwähnt besitzt unser Space Invaders Clone drei Screens die jeweils von GameScreen ableiten. Wir werden diese zuerst besprechen und dann als letzten Punkt die Activity die all dies managed betrachten.
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 User den Screen berührt hat. Das ganze ist nicht alt zu schwer zu implementieren, schaun wir es uns an:
public class StartScreen implements GameScreen
{
Mesh backgroundMesh;
Texture backgroundTexture;
Mesh titleMesh;
Texture titleTexture;
boolean isDone = false;
SoundManager soundManager;
Font font;
Text text;
String pressText = "Touch Screen to Start!";
... to be continued ...
Erstmal leiten wir von GameScreen ab. Als nächstes definieren wir uns ein paar Member. Dazu zählt die Texture und das Mesh für den Hintergrund bzw. für das Logo. Ein Flag namens isDone speichert ob der Screen fertig ist. Einen SoundManager brauchen wir auch da wir Hintergundmusik abspielen wollen. Auch eine Instanz von Font und Text brauchen wir zur Touch-Aufforderung. Der letzte Member 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" ) );
backgroundTexture = new Texture( gl, bitmap, TextureFilter.MipMap, TextureFilter.Nearest, TextureWrap.ClampToEdge, TextureWrap.ClampToEdge );
bitmap.recycle();
bitmap = BitmapFactory.decodeStream( activity.getAssets().open( "title.png" ) );
titleTexture = new Texture( gl, bitmap, TextureFilter.Nearest, TextureFilter.Nearest, TextureWrap.ClampToEdge, TextureWrap.ClampToEdge );
bitmap.recycle();
}
catch( Exception ex )
{
Log.d( "Space Invaders", "couldn't load textures" );
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 Resourcen. Zuerst bauen wir das Hintergrund-Mesh gleich 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 mapped auf den oberen Teil folgender Texture:
Als nächstes laden wir die Texturen für Hintergrund und Logo. Danach instanzieren wir einen neuen SoundManager. Abschließend bauen wir uns noch einen Font und eine Text Instanz der wir den Aufforderungstext setzen. Interessant hierbei ist das 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 des isDone Flags 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_TEXTURE_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 );
backgroundTexture.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 );
titleTexture.bind();
titleMesh.render(PrimitiveType.TriangleFan);
gl.glLoadIdentity();
gl.glTranslatef( activity.getViewportWidth() / 2 - font.getStringWidth( pressText ) / 2, 100, 0 );
text.render();
gl.glDisable( GL10.GL_TEXTURE_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. Das selbe 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()
{
backgroundTexture.dispose();
titleTexture.dispose();
soundManager.dispose();
font.dispose();
text.dispose();
backgroundMesh.dispose();
titleMesh.dispose();
}
Wie nicht anders zu erwarten geben wir hier wieder alle Resourcen frei. Augenmerk soll auch auf das Disposen des SoundManagers gelegt werden. Damit drehen wir auch wieder die Muskik ab die sonst weiterlaufen würde!
Die GameOverScreen Klasse ist nahezu ident mit der StartScreen Klasse. Einziger Unterschied ist das 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 ident mit der der StartScreen Klasse. Ich erspare mir hier also die Auflistung des Codes.
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 Member: 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. Zweiteren werden wir später dazu verwenden ein pausiertes Spiel fort zu setzen. 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 updaten 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 den User Input 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 wieviel von der Schiffsgeschwindigkeit Ship.SHIP_VELOCITY wirklich herangezogen wird um das Schiff zu bewegen. Je mehr der User 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 die für uns das erstellen eines Schusses sowie das Prüfen ob schon ein Schuss des Schiffes am Spielfeld ist übernimmt. Mehr Code brauchen wir für die Verarbeitung des User Inputs nicht!
... continued ...
public boolean isDone( )
{
return simulation.ship.lives == 0;
}
... to be continued ...
Der Screen ist fertig sobald das Schiff kein Leben mehr hat. Simpel.
... 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 das die Klasse zum einen das Interface SimulationListener implementiert und wir in den Konstruktoren der Simulation die Instanz der Klasse auch als Listener setzen. Das nützen wir aus um die Soundeffekte abzuspielen!
... continued ...
@Override
public void explosion()
{
soundManager.playExplosionSound();
}
@Override
public void shot()
{
soundManager.playShotSound();
}
Jedesmal 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 bitte den entsprechenden Effekt abspielen soll.
Und das war das gesamte Spiel. Es fehlt nur noch die Activity dann sind wir komplett fertig!
Zum Abschluss müssen wir unsere 3 Screens noch irgendwie koordinieren. Auch müssen wir den Activity Life Cycle implementieren. Das machen wir in einer Activity namens SpaceInvaders. Die Activity started 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 einen Druck auf die Home-Taste unterbrochen wird. Hier hilft uns die schöne Kapselung der Simulation. Schaun 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 zum einen einen GameScreen und zum anderen 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 die Simulation reinschreiben so wir uns im GameLoop befinden. Im Konstruktor lesen wir lediglich das Bundle aus und checken ob eine Simulation darin enthalten ist. Ist dem so speichern wir sie in den simulation Member 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 Stati 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 checken ob gerade der GameLoop aktiviert ist und uns von diesem die Simulation holen die wir dann in das Bundle unter dem Key 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, ein Muss. In onPause geben wir auch den aktuell aktiven Screen frei so dieser bereits gesetzt ist und speichern die Simulation des GameLoop in unseren Member simulation so 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 ja 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 der Member simulation gesetzt ist oder nicht. Für den Fall das er 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 Resourcen wir Textures 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 updaten und rendern wir den aktuellen Screen. Als nächstes Fragen wir ob der Screen fertig ist damit wir den nächsten Screen aktivieren können. Ist der Screen fertig so geben wir zuerst all seine Resourcen frei. Als nächstes prüfen wir welcher Screen aktiv war. Im Falle des Start-Screens bauen wir einen neuen GameLoop, im Falle des GameLoop bauen wir einen neuen Game-Over-Screen und im Falle des Game-Over-Screen starten wir den Start-Screen neu. Abschließend erhöhen wir einen Frame-Counter Member namens frames und prüfen ob eine Sekunde seit der letzten Zeit-Messung vergangen ist. Ist dem 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 aller letzten Punkt müssen wir uns noch anschaun 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 ist ein großer Punkt in Android. Da wir mit einer VM arbeiten die den Code nur interpretiert, also nicht direkt als CPU Instruktionen ausführt müssen wir auf ein paar Dinge achten. Hier eine kleine Auswahl der wichtigsten Tipps:
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 Sourcecode wird niemanden erspart bleiben der wirklich gute Spiele schreiben will. Im Netz gibt es zu allen möglichen Themen Unmengen an Material welches sich so auch auf Android anwenden lässt. Abschließend möchte ich daher noch einige Quellen auflisten die der geneigte Leser sich zu Gemüte führen kann.
Bei Fragen, Anregungen, Korrekturen und Drohungen einfach eine e-mail an badlogicgames at gmail dot com schreiben. Ich bin sehr antwortfreudig.
Macht's gut!