Worm-X OpenGL

So nach wochenlanger Schweißarbeit ist es nun endlich geschafft, das 2. Spiel ist fertig geworden. Bevor ich anfange möchte ich mich hier nochmals bei allen Beteiligten für Ihre Mitarbeit bedanken, dies waren
  • Chartus: für die 3D-Modelle
  • Jay-Edit: für die Hintergrundmusik
  • Lucas de Vil: für den Betatest und die guten Anregungen

Worum geht es:

Worm-X ist ein 'simples' Arcadespiel, in dem man so viele Punkte wie möglich erreichen muss. Nach jeder durchgespielten 5. Runde kommt man in eine Bonsurunde die wiederrum 3 'Waves' beinhaltet. Wenn die erreichte Punktezahl hoch genug ist, darf man sich in eine Highscorliste eintragen. Das Ganze ist ein wenig an die Arcadeklassiker der 80'er angelegt (ja dort hat man sich so in eine Highscorliste eingetragen ). Die Spieltiefe ist entsprechend flach, was aber meiner Meinung nach nicht schlimm ist, da es so einfacher ist den Quellcode zu verstehen. Moorhuhn hatte wohl die gleiche Spieltiefe !!
WormX

Los gehts:

Im Gegensatz zum ersten Teil ( Space Invaders) ist Worm-X in 3D. Auch wenn es im ersten Moment so aussieht als würde man 'flach' auf das Spielgeschehen schauen ist es doch in Wiklichkeit eine Perspektivische Ansicht, allerdings mit einem recht kleinen 'Field of View' Winkel der Kamera (5 Grad um genauer zu sein). Je kleiner dieser Winkel ist um so mehr bekommt man eine Ortho-ähnliche (also ohne Perspektive) Ansicht. Desweiteren ist die Kamera auf der X-Achse um 90 Grad gekippt, das heißt man schaut von oben auf das Spielfeld das auf der XZ- Achse liegt. Ursprünglich wollte ich eine freibewegliche Kamera einbauen, in der man auch eine FPS-Ansicht des Spiels hat, das hab ich aber wieder verworfen, da es nicht den gewünschten Effekt hatte.

Übersicht:

Das Spiel besteht im Wesentlichen aus 4 großen Blöcken, diese sind
  • das Intro
  • der Auswahlschirm
  • das Spiel ansich
  • der Bonuslevel
Ich habe mal ein kleines Diagramm erstellt, dort sieht man grob wie die einzelnen Klassen miteinander verdrahtet sind. Das Diagramm zeigt allerdings nicht alle Verbindungen der einzelnen Klassen im Detail, das würde zu unübersichtlich werden, aber man bekommt einen groben Überblick, wie das Spiel aufgebaut ist. Das Spiel hat meiner Meinung nach einige Dinge die für andere Spiele durchaus interessant sein können, dies wäre zum Beispiel das Abspielen eines Quicktime-Filmes im Vollbildmodus, oder das Erstellen und rendern von VBO's (Vertex Buffer Objects) im Zusammenhang mit Wavefront-Models. Auch die Klasse für die Shader kann man sehr einfach erweitern und in andere Spiele einbauen. Diese Sachen werde ich alle im Detail erklären.

Spielobjekte / OpenGL-Wrapper:

Alle Klassen die zu den OpenGL-Wrapper Klassen gehören (NEIN ES IST KEINE ENGINE!!!) beginnen mit CFX alle anderen die direkt zum Spiel gehören haben dieses Prefix nicht.

Aufbau des Spiels:

Beim Start, wird zuerst versucht, einen OpenGL-Context (CFXOpenGLContext) mit den gewünschten Bildeinstellungen (GlobalDefinitions) zu erstellen. Wenn das geklappt hat wird eine Instanz von der Klasse Game erzeugt. Dort wird dann die Methode 'setupGame' aufgerufen die ihrerseits alle benötigten Objekte erzeugt.

Einwurf: Ich habe den Quellcode mit viel Kommentaren versehen, ich hoffe es ist alles verständlich

Wenn alles geklappt hat wird in die Methode 'enterGameCycle' gesprungen und das Intro wird abgespielt. Mit der Leertaste kommt man dann in den Auswalschirm, der dann selbsterklärend ist.
WormX

Die MainLoop:

Kernstück des Spiels ist die Methode 'enterGameCycle', hier werden im groben 4 Dinge erledigt:
  1. ein AutoreleasePool erstellt
  2. Die Zeit berechnet, die das System braucht um einen Durchlauf zu rendern.
  3. Die KeyEvents abgefangen
  4. Über eine simple Switch-Anweisung das komplette Spiel gesteuert
Ich denke es ist nicht nötig zu sehr ins Detail zu gehen, was innerhalb der MainLoop passiert, da ich wie gesagt sehr viele Kommentar eingebaut habe. Ich möchte lieber die Dinge genauer erklären die meiner Meinung nach wichtig sind.
WormX
Wie schon gesagt, hab ich die Möglichkeit eingebaut in Quicktimefilm abzuspielen, dies war in so fern tricky als das es so gut wie keine Informationen dazu gibt, wie man das macht. Glücklicherweise hab ich dann doch noch einen Codeschnipsel bei Apple gefunden. Funktionieren tut das Ganze so: Man erzeugt einen 'Link' zum Maindisplay, wenn das geklappt hat erstellt man eine Callback-Funktion die immer dann aufgerufen wird wenn ein neues Frame aus dem Film fertig zum rendern ist. Diese Frame wird dann in eine zuvor erstellt Texture kopiert. Zum Schluss wird ein Quad erzeugt und diese Texture (mit dem Frame drin) auf das Quad gemappt. Das war dann schon alles
ACHTUNG: Während der Film abgespielt wird, dürfen KEINE! anderen OpenGL-Aufrufe gemacht werden. OpenGL ist NICHT Threadsafe!! Sonst geht es euch wie mir, das das System einfriert :-(

Highscore:

Je nachdem wie viele Punkte man erreicht hat, darf man sich in die Highscoreliste eintragen. Ich habe dafür 3 Klassen erstellt
  • HighScore ist im Prinzip ein Eintrag (Name, Punkte) in der Highscoreliste
  • HighScoreController lädt und speichert die Highscoreliste
  • HighScoreListWriter dort trägt man sich in die Highscoreliste ein
WormX

3D-Models:

Die 3D-Models werden über die Klasse CFXWavefrontModel geladen, hier sind einige Dinge zu beachten. Eine Wavefront-Datei ist eine simple Textdatei, die man entsprechend parsen kann. Folgende Einschränkungen sind zu beachten, wenn man die Klasse benutzen will:
  1. Das Model muss 'trianguliert' sein, das heißt das Model muss aus Dreiecken bestehen
  2. Es müssen Normale vorhanden sein (Ok, im Prinzip kann man die selber berechnen)
  3. Es müssen Texturekoordinaten vorhanden sein.
  4. Das Material wird nicht berücksichtigt
Die für uns wichtigen Teile in einer Wavefront-Datei sind:
  • alle Zeilen die mit einem 'v' beginnen, diese beinhalten die Vertexdaten
  • alle Zeilen die mit einem 'vt' beginnen, diese beinhalten die Texturekoordinaten
  • alle Zeilen die mit einem 'vn' beginnen, diese beinhalten die Vertexnormale
  • alle Zeilen die mit einem 'f' beginnen, diese beinhalten die Faceinformation (was nichts anderes als ein Index auf die oben genannten Punkte ist)
  • Achtung, mir ist aufgefallen das die Normale nicht immer normalisiert exportiert werden, deshalb habe ich die Methode
    tmp = normalizeVector(tmp);
    
    eingebaut, die das übernimmt. Wir erinnern uns, um ein Objekt 'sauber' zu beleuchten, muss es über normalisierte Normale (was ein Wort) verfügen. Also wie gesagt wird die Datei mit einem simplen NSScanner gescannt, und die Daten werden entsprechend aufbereitet in 2 Arrays gespeichert. Einmal die Daten (Vertex, Texturekoordinaten, Normale) selbst (_data) und einmal die Indexe (_vertexIndicies) auf diese Daten.

    Danach wird überprüft ob VBO's (Vertex Buffer Objects) verfügbar sind, wenn ja werden diese erzeugt. Wenn VBO's unterstützt werde, braucht man eigentlich die beiden Arrays (_data, _vertexIndicies) nicht mehr, da die Daten dann im Grafikkartenspeicher liegen. Ich habe sie aber trotzdem generiert, für den Fall das VBO's nicht unterstützt werden. Dann nämlich werden 'normale Vertexarrays mit den beiden Arrays erzeugt.

    An dieser Stelle noch ein kurze Erklärung zu Vertexarrays / VBO's:
    Die wohl schnellste Methode um komplexe Geometrie zu rendern sind VBO's im Zusammenhang mit 'Indexed Arrays' Das heißt man hat einmal die Rohdaten (Vertexdaten, Normale, Texturekoordinaten) und einmal die Faces (Indexe auf die Rohdaten) In unserem Fall sieht das dann so aus:
    GLfloat *tmp = [_model data];		
    glVertexPointer(   3, GL_FLOAT, 8*sizeof(GLfloat), tmp );				//Stride und Zeiger auf Daten
    glTexCoordPointer( 2, GL_FLOAT, 8*sizeof(GLfloat), &tmp[3] );				// ''
    glNormalPointer(      GL_FLOAT, 8*sizeof(GLfloat), &tmp[5] );				// ''
    glDrawElements( GL_TRIANGLES, [_model numIndexes]*3, GL_UNSIGNED_INT, [_model vertexIndicies] );
    
    Die Rohdaten liegen in [_model data] und die Indexe in [_model vertexIndicies].

    Das Problem bei dieser Methode ist, das alle Daten (also alle Vertexdaten, Texturekoordinaten und Normale) alle gleich lang sein müssen, da sonst der Stride (also Abstand zwischen den einzelnen Daten nicht mehr stimmen würde) Das ist aber leider nicht immer der Fall (einfach mal eine Wavefront anschauen).

    Ich habe da ein bisschen nachgeholfen, ich habe die Daten die mit einem 'f' gekennzeichnet sind (also Faces bzw. Indexe) genommen und anhand dieses Wertes einfach die entsprechenden Vertex, Texture und Normale ausgelesen und in meinem Array abgespeichert. Das ist zwar nicht Sinn und Zweck von Indexierten Daten (da durchaus Dubletten vorkommen können), aber für unsere Zwecke die einfachste Methode, außerdem spielt es bei der geringen Anzahl an Dreiecken keine besonders große Rolle, da man so gut wie kein Unterschied bei der Performance bemerkt.

    Kollision der Models / Gameobjekte:

    Auch diesmal kommt eine simple Box-Box Kollisionserkennung zum Einsatz. Das berechnen der BoundingBox übernimmt die Methode
    -(void) calculateBBox:(NSArray *)vertices
    
    in der Klasse CFXWavefrontModel beim Laden der Geometrie selbständig. Somit hat man keine Arbeit mehr damit. Wie ein BoundingBox-Kollision funktioniert habe ich ja im ersten Teil beschrieben.

    Gameobjekte:

    Alle Objekte die man im Spiel sieht, sind von der Klasse GameObject abgeleitet. Diese Klasse ist es auch, die die Objekte später auf den Schirm rendert. Die einzelnen Objekte unterscheiden sich in der Regel nur dadurch, das sie sich unterschiedlich bewegen, was man in der Methode
    - (void)animate:(float)deltaTime
    
    der jeweiligen Objekte sehen kann. Diese 'arbeiten / bewegen' sich relativ autark vom restlichen Spiel. Das bedeutet man muss sich zum Beispiel nicht darum kümmern, das sie sich aus dem Spielfeld bewegen. Die Verwaltung der Objekte übernimmt die Klasse GameObjectsManager (NSMutableArray). Eine Besonderheit ist das BonusObjekt, dieses bewegt sich wie schon im Blog erwähnt über einen Catmull-Rom Spline Die Vertexdaten für den Spline liegen in Splines.h

    Goodies:

    wie der Name schon sagt, wenn man diese Objekte einsammelt erhält man was Schönes dafür. Ansonsten gilt hier das Selbe wie bei den Gameobjekten (siehe oben)

    Schüsse:

    Es gibt 4 verschiedene Arten von Schüssen die der Spieler abfeuern kann: SIMPLE_SHOT: der Standarschuss

    SUCKER_SHOT: ein Downgrade des Schusses, dieser bewegt sich langsamer und hat nur eine begrenzte Reichweite

    THREE_TIMES_SHOT: Schießt in 3 Richtungen (dieser ist auch der einzige der ein 'Barrier-Objekt' zerbröseln kann

    WAVE_SHOT: Diesen gibt es in 2 Versionen einmal als einzelnen Schuß und einmal als doppelten (nicht der zum trinken;-) ) Dieser Schuß bewegt sich in einer Schlangenlinie über den Schirm

    Alle Schüsse werden im ShootManager (NSMutableArray) verwaltet. Der Grund warum ich für die Schüsse einen extra Manager angelegt und nicht in den GameObjectsManager mit eingebaut habe ist, zum einen die Kollisionserkennung (diese reduziert sich dadurch erheblich) und das Rendern, weil die Schüsse, wie auch die Partikelsysteme mit aktiven Blending und ohne Beleuchtung gerendert werden. Dadurch entfallen einige 'State-Changes' wärend des Rendervorgangs.

    Playfield:

    Die Klasse Playfield dient dazu die Spielobjekte in einem unsichtbaren Raster zu platzieren. Dies ist nötig, da sonst der Wurm nicht 'sauber' durch die einzelnen Spielobjekte bewegt werden kann. Die wichtigen Methoden darin hab ich nochmals entsprechend kommentiert.

    Shader:

    Ich habe diesmal einen Shader (Per Pixel Lighting) mitteingebaut, damit man mal sieht wie das mit der Einbindung von Shadern funktioniert. Zuerst wird mal geprüft, ob Shader überhaupt vom System unterstützt werden, wenn nicht wird das Spiel mit der Standardbeleuchtung von OpenGL gerendert, was leider nicht ganz so schön aussieht. Die Handhabung der Shaderklasse ist recht einfach und funktioniert so: Prüfen ob Shader unterstützt werden
    
     const GLubyte* extensions = glGetString(GL_EXTENSIONS);
    if ((GL_FALSE == gluCheckExtension((GLubyte *)"GL_ARB_shader_objects",       extensions)) ||
    	(GL_FALSE == gluCheckExtension((GLubyte *)"GL_ARB_shading_language_100", extensions)) ||
    	(GL_FALSE == gluCheckExtension((GLubyte *)"GL_ARB_vertex_shader",        extensions)) ||
    	(GL_FALSE == gluCheckExtension((GLubyte *)"GL_ARB_fragment_shader",      extensions)))
    {
    	_shadersAvailable = NO;
    }
    
    Shader laden
    [_shaderManager createShader:@"PerPixelLighting" 
    						vertexShader:[bundle pathForResource: @"PerPixelLighting" ofType: @"vert"]
    					  fragmentShader:[bundle pathForResource: @"PerPixelLighting" ofType: @"frag"]];
    		[_shaderManager useShader:@"PerPixelLighting"];
    
    Shader einschalten / setzten
    if(_glslSupported)
    		[_shaderManager attachCurrentShader];
    	else
    		[_openGLState setLightState:YES];
    
    rendern
    glEnableClientState( GL_VERTEX_ARRAY );
    glEnableClientState( GL_TEXTURE_COORD_ARRAY );
    glEnableClientState(GL_NORMAL_ARRAY)
    .
    .
    .
    
    In unserem Beispiel ist das alles recht einfach, da alle Models mit dem selben Shader gerendert werden, in komplexeren Spielen hat man natürlich viel mehr Shader auf einmal. Aber ich denke es zeigt doch schon mal wie man mit Shadern umgeht. Ich kann hier kein Tutorial bzw. nähere Erklärung zu GLSL liefern, dazu gibt's genügend andere Quellen, aber hier mal ein paar Links für diejenigen die sich ein wenig einarbeiten wollen:
    • http://www.lighthouse3d.com/opengl/glsl/
    • http://www.clockworkcoders.com/oglsl/tutorials.html
    • http://www.ozone3d.net/index.php
    Ein absolute Pflicht zu dem Thema ist natürlich das OrangeBook An dieser Stelle will ich noch meine Unmut zum Thema GLSL loswerden, sucht man nach 'anständiger' Literatur zum Thema sieht es mit der oben genannten Ausnahme sehr schlecht aus, nahezu alle Bücher sind für DirectX geschrieben. Auch Werkzeuge (Programme) um GLSL-Shader auf dem Mac zu erstellen gibt es (außer die von Apple mitgelieferten) so gut wie keine! Aber ich denke, das ist man ja gewohnt.

    Partikelsysteme:

    Ich habe für jedes Partikelsystem eine eigene Klasse geschrieben, wie schon mal von mir erwähnt gibt es bessere Methoden um ein flexibles Partikelsystem zu schreiben (gescriptet zum Beispiel), aber für unser Beispiel ist es absolut ausreichend.

    Was noch übrig bleibt

    Es gibt eigentlich nicht viel mehr dazu zu sagen, wie schon erwähnt ist der Quellcode gut dokumentiert, ich hoffe ich habe jetzt nichts mehr vergessen

    verwendete Tools:

    Ich will hier nochmals kurz auf die Tools hinweisen, die für Worm-X benutzt wurden. Dies soll nur als Hinweis dienen, natürlich gibt es auch andere Programme die das selbe können.
    • Xcode (wer hätte das gedacht)
    • Cinema 3D (3D Models)
    • Modo 301 (3D Models / Intro)
    • Wings 3D (3D Models)
    • Photoshop (Texturen / Grafiken)
    • Gimp (Texturen)
    • Audacity (Sounds)
    • Logic Pro (Hintergrundmusik)
    • OmniGraffle (für das Diagramm)

    Schlusswort:

    So, wie gesagt war es diesmal schon mehr Arbeit als 'SpaceInvaders' aber es hat natürlich sehr viel Spaß gemacht. Ich hoffe ich konnte damit ein wenig zeigen, wie man Spiele auf'm Mac macht (auch mit ObjC!!). Stellt sich vielleicht noch die Frage, ob man auch anspruchsvollere Spiele mit ObjC machen kann. Klar kann man das!! das sollte kein Problem sein, wer unbedingt mal einen coolen FPS-Shooter machen will, der kann sich gerne bei mir melden, ich bin auf jeden Fall dabei.

    Sourcecode

    Link
    der Quellcode steht unter der GPL-Lizenz