Sucht man Informationen zur Spieleprogrammierung, sei es in einem Buchladen oder im Internet, wird man geradezu erschlagen damit. Leider sind diese fast ausschließlich auf Windows mit C/C PlusPlus zugeschnitten.
Um diese Lücke mal ein bisschen zu füllen, hab ich mich entschlossen, hier mal einige Tutorials zu Spieleprogrammierung mit Obj.C / OpenGL/OpenAL zu erstellen.
Generell kann man sagen, das sich die Entwicklung von Spielen auf einem Mac, nicht sonderlich zu anderen Systemen unterscheidet. Weshalb man natürlich auch Tutorials die eigentlich für Windows gemacht sind, sehr gut als Lernhilfe nehmen kann.
Gerade im Bezug auf OpenGL/OpenAL gibt es so gut wie keine Unterschiede zwischen den einzelnen Plattformen (das ist ja auch das Schöne daran).
Hat man erst einmal einen Grafik-Kontext (OpenGL) erstellt, ist das Zeichnen von Objekten auf allen Systemen absolut identisch.
Also wird ein Aufruf von
glBegin( GL_TRIANGLES ); glVertex3f( 0.0f, 1.0f, 0.0f ); glVertex3f( -1.0f, -1.0f, 0.0f ); glVertex3f( 1.0f, -1.0f, 0.0f ); glEnd();auf eine Linuxrechner genauso ein Dreieck erstellen wie auf einem Win-/Mac.
Die Integration von OpenGL, ist auf keinem System so gut wie auf einem Mac. Gerade in Verbindung mit Obj.C lassen sich in kürzester Zeit z.B. Editoren bauen. Wer einmal mit dem Windows API einen Editor gebastelt hat, der weiss was ich meine.
Apple selbst empfiehlt OpenAL als Soundbibliothek wenn es darum geht, Spiele oder ähnliches zu vertonen. CoreAudio wäre in diesem Zusammenhang zu „Low-Level“. Etliche sehr bekannte Spiele wie z.B. Doom3, Unreal2 oder Hitman2 benutzen OpenAL. Gerade hierzu werden noch einige kleinere Tutorials folgen, da es hier meiner Meinung nach, nicht sehr viel Informationen gibt.
Im ersten Teil der Serie habe ich mich entschlossen, einen kleinen Klassiker aus der Mottenkiste zu graben. Es wird hier darum gehen, Space Invaders auf den Mac zu bringen. Aber warum gerade Space Invaders und warum gerade so was „einfaches“??.
Zum einen bin ich ein großer Retrofan und zum anderen finde ich, dass es ein guter und vor allem einfacher Einstieg in das Thema ist.
Das Internet ist voll von angefangenen Spielen, bei denen irgendwann keiner mehr Lust hatte sie fertig zu machen. Ich bin auch der Meinung das, wenn man was angefangen hat, es auch zu Ende bringen soll. Und mit so einem „kleinen Spielchen“ ist es wahrscheinlicher es fertig zu stellen, als wenn man sich gleich an ein riesen Projekt wagt, das dann aus mangelnder Selbsteinschätzung nie das Licht der Welt erblickt.
Wer Kenntnisse in Obj.C hat sollte keinerlei Probleme haben, den Code zu verstehen. Ich habe das Spiel absichtlich über mehrere Klassen verteilt, damit es schön übersichtlich bleibt. So lassen sich auch später sehr einfach Dinge dazubauen.
Das Ganze ist ein simples 2D Spiel, also müssen wir uns zu Anfang nicht mit irgendwelchen
„schwierigen“ 3D-Berechnungen rumplagen
Natürlich hätte man es auch komplett ohne OpenGL realisieren können, aber da ich später
ein wenig anspruchsvollerer Spiel (in 3D) machen will, kann es nichts schaden sich schonmal
damit anzufreunden
Da ich mal davon ausgehe, das jeder Space Invaders kennt, werde ich nicht näher auf den Inhalt des Spiels eingehen
Beginnen wir mit dem GameLoop eines einfachen Spiels der unabhängig von der Art der Sprach in Pseudocode in etwa so aussieht:
Initialisierung GameLoop: Prüfe Spieler-Input Animiere und bewege Spielobjekte Kollisionstests / Spielephysik Rendern / Soundausgabe Gehe zurück nach GameLoop: Aufräumen Ende
In unserem Beispiel wäre der Ablauf wie folgt:
Wir erstellen uns einen OpenGL-Grafikkontext den wir ja zum rendern brauchen. Danach wird das Spiel initialisiert und anschließend in den GameLoop gesprungen.
Hier mal der GameLoop aus in Objective C und Cocoa:
// Immer wahr, Endlosschleife while (1) { // Speicherverwaltung soll das System übernehmen NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; // Gibt die aktuelle Zeit start = [NSDate timeIntervalSinceReferenceDate]; // Resetet OpenGL glLoadIdentity(); glClear( GL_COLOR_BUFFER_BIT ); // Escape beendet die while-Schleife if([self pollingKeyboard] == ESC) { break; } // Spiellogik switch (_gameState) { case GAMESTATE_INTRO: { [_intro renderIntro:_timeDelta]; }break; case GAMESTATE_PLAY: { [self recycleDeadObjects]; if([_player isDead]) { [self playerIsDead]; } else { [self process]; [self render]; } }break; case GAMESTATE_GAMEOVER: { [self renderInfoText]; [_gameOver renderGameOver:_timeDelta]; }break; } // Sendet die Daten an die Grafikkarte [context flushBuffer]; // Gibt die aktuelle Zeit end = [NSDate timeIntervalSinceReferenceDate]; // Errechnet, wieviel Zeit der GameLoop brauchte _timeDelta = end-start; // Speicherverwaltung [pool release]; }
Da wir uns in einer Endlosschleife befinden müssen wir mindestens 2 Punkte bachten
Eine andere Möglichkeit wäre einen Timer zu installieren und den RunLoop über diesen zu triggern, was meiner Meinung nach aber nicht die beste Lösung wäre. Da doch sehr viel an Rechenzeit auf der Strecke bleiben würde.
Nun zum Code wie gesagt wir erstellen einen AutoreleasePool 1) und holen uns danach die ReferenzZeit. Diese benötigen wir, weil unser Spiel unabhängig von den erzielten Frames Per Seconds (FPS) auf allen Systemen gleich schnell laufen soll.
Ich habe in diesem Spiel komplett auf einen Framecounter verzichtet, in der Praxis ist er aber in so fern sehr wichtig, als das man zu jeder Zeit wissen sollte wie schnell ein Spiel denn läuft. Hier geht es nicht ums Protzen ala MEINE ENGINE SCHAFFT 3000 FPS, sondern schlicht darum, herauszufinden warum z.B. das Rendern eines simplen Models unsere Framerate total in den Keller zieht.
Eine noch spielbare Framerate liegt bei ca. 30, das heißt alles was stark unter diesen Wert fallen würde, würde nur noch unspielbar über den Schirm ruckeln. Interresant ist auch, das ein Spiel auch durchaus zu viel Frames erreichen kann. Was sich jetzt vielleicht seltsam anhört, läßt sich einfach erklären. Wir speichern die Zeit und die Bewegung der einzelnen Objekte in float-Werten, wenn nun aber sehr viele Frames erreicht werden (der Zeitwert ist z.B. 0.000001) käme es irgendwann aufgrund der Genauigkeit von float-Werten dazu, das sich unsere Objekte gar nicht mehr bewegen würden, weil der Zeitwert einfach zu viele Nachkommastellen hat, die unser float nicht mehr darstellen kann.
Ein Ausweg wäre z.B. einen double zu nehmen, da dieser die doppelte Genauigkeit eines float-Wertes hat, allerdings braucht dieser auch mehr Speicher.
Eine andere Möglichkeit wäre, ein Framebremse einzubauen, die z.B. bei einer Framerate über 120, solange irgendwas machen würde, bis die Rate wieder darunter fallen würde.
Danach folgen
glLoadIdentity(); glClear( GL_COLOR_BUFFER_BIT );womit wir die Einheits Matrix laden und den Bildschrim löschen. Danach prüfen wir in
if([self pollingKeyboard] == ESC) { break; }ob der Anwender die ESC-Taste gedrückt hat, wenn dem so ist, springen wir aus der while-Schleife raus und kehren zu OpenGLContext.m zurück, dort werden dann die benötigten Aufräumarbeiten durchgeführt und das Spiel beendet. Innerhalb der switch-Anweisung prüfen wir, in welchem Zustand unser Spiel ist:
und zum Schluß führen wir
[context flushBuffer]; end = [NSDate timeIntervalSinceReferenceDate]; _timeDelta = end-start; [pool release];aus.
Beim Anlegen unseres Grafik-Kontext haben wir mit
ADD_ATTR(NSOpenGLPFADoubleBuffer);OpenGL angewiesen einen Doublebuffer anzulegen, was so viel bedeutet, das ein „zweiter unsichtbarer Bildschirm“ erzeugt wird, auf den dann gezeichnet wird. Wenn dann der Zeichenvorgang beendet ist, werden beide „Schirme“ mittels
[context flushBuffer];getauscht, also der unsichtbare Hintergrund wird in den Vordergrund gebracht.
Danach holen wir uns wieder die Zeit und berechnen die Differenz die gebraucht wurde um einmal durch unseren RunLoop zu gehen, mit dieser Differenz werden dann alle Objekte animiert und bewegt. Zum Schluß wird der Pool released und es geht wieder von vorne los.
Ich will hier nun auf die einzelnen Klassen etwas genauer eingehen.
globale Definitionen die im Spiel gebraucht werden
Diesen hab ich als typischen Singleton angelegt, weil er ja nur einmal pro Spiel gebraucht wird. Zu Anfang werden alle benötigten Sounds geladen (loadSounds) Zum Abspielen eines Sounds reicht dann ein simples playSound mit dem angegebenen SoundIndex.
Alle Objekte die auf dem Schirm angezeigt werden (Spieler, Gegner Schüsse. usw…) werden von GameObject abgeleitet. Dieser GameObjectsHandler hat ein simples MutableArray das eben diese Objekte aufnehmen kann. So ist es sehr einfach alle Objekte in einem Durchlauf zu animieren, bewegen, auf Kollision zu prüfen und natürlich auch zu rendern.
wie eben schon erwähnt sind alle Objekte von GameObject abgeleitet. GameObject ist im Prinzip die abstrakte Klasse für alle Objekte auf dem Schirm. In C-PlusPlus wäre das dann eine virtual-Class Diese speichert unter anderem die Position, die Größe und die Geschwidigkeit eines Objektes
Hierzu muss nicht viel gesagt werden, der Spieler besitzt alle Attribute und Methoden von Gameobject und wird demenstrechend bewegt und gerendert, eine Animation gibt es nicht, der Spieler besteht aus einem einzigen simplen Bild. Damit der Spieler nicht ständig schießen kann, hab ich eine kleine Zeitsperre eingebaut die so aussieht:
if(!_canShot) { _counter += 1.5*timeDelta; if(_counter >1.0f) { _canShot = YES; _counter = 0.0f; } }Was bedeutet wenn er gerade geschossen hat (_canShot) dann wird ein counter hochgezählt. Hat dieser einen bestimmten Wert (>1.0f) dann kann er wieder schießen. Hat er geschossen wird ein Shot-Objekt erzeugt un dem GameObjectsHandler übergeben. Und zum Schluß noch ein Schuß-Sound abgespielt.
Der Invader macht zu Anfang 8 und später dann immer 16 Steps in eine Richtung, danach rutscht er um 35 Pixel nach unten und bewegt sich in die entgegengesetzte Richtung. Zu jeder Bewegung wird immer der passende Sound abgespielt. Und mit
long r=random(); if(r/1000 < 45000) . .ziehen wir eine Zufallszahl, sollte diese kleiner als als der genannte Wert sein, wird ein Schuß erzeugt dieser wieder dem GameObjectsHandler übergeben. Die Animation besteht aus nur 2 Bilder weshalb das Rendern auch ziemlich einfach ausfällt:
glBindTexture(GL_TEXTURE_2D, _texture); glBegin(GL_QUADS); if(_animSequence) { glTexCoord2f(0, 0); glVertex2i(_xPos,_yPos); // unten links glTexCoord2f(0.5f, 0); glVertex2i(_xPos+_xSize, _yPos); // unten rechts glTexCoord2f(0.5f, 1.0f); glVertex2i(_xPos+_xSize, _yPos+_ySize); // oben rechts glTexCoord2f(0, 1.0f); glVertex2i(_xPos,_yPos+_ySize); // oben links } else { glTexCoord2f(0.5, 0); glVertex2i(_xPos,yPos); // unten links glTexCoord2f(1.0f, 0); glVertex2i(_xPos+_xSize, _yPos); // unten rechts glTexCoord2f(1.0f, 1.0f); glVertex2i(_xPos+_xSize, _yPos+_ySize); // oben rechts glTexCoord2f(0.5f, 1.0f); glVertex2i(_xPos,yPos+_ySize); // oben links } glEnd();Wir binden unsere Texture und rendern abhängig von _animSequence, entweder den ersten oder den zweiten Bildausschnitt der Textur. Das realisieren wir, indem wir die TexturKoordinaten in
glTexCoord2fanpassen.
Interressant hier ist nur, das wenn der Schuß sich ausserhalb einer bestimmten Y-Koordinate befindet, das er dann automatisch gelöscht wird:
//Bewegen _yPos+=_velocity*timeDelta; //Schuss außerhalb des Bildschirms dann löschen if( _yPos >(HEIGHT-100) || (_yPos+_ySize) < 0) { _isDead=YES; }
Diese wird immer dann erzeugt wenn der Spieler / Invader getroffen wird. Nach Ablauf einer bestimmten Zeit wird sie wieder gelöscht:
if(_animCounter > _holdAnimation) _isDead = YES;
Nichts besonderes, auch hier wird geprüft ob er ausserhalb des Bildschirms liegt und entsprechend gelöscht:
_xPos += _velocity*timeDelta; if( (_xPos > WIDTH+100) || (_xPos <-100) ) _isDead = YES;
Hier wird zuerst ein Pattern aufgebaut:
unsigned char stips[]={ 1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,
1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,
0,0,1,1,1,1,1,1,1,1,1,1,1,1,0,0
};
Dieses Muster wird dann „geparst“, sollte eine 1 dort stehen
wird ein Block (BulletHole) erzeugt. Der vom
Spieler oder vom Gegner abgeschossen werden kann.
Anders als bei z.B. dem Spieler gibt es hier keine Textur, hier
wird lediglich pro Block ein weises Rechteck gerendert.
Weshalb auch zuerst die Texturierung ausgeschaltet wird.
[[OpenGLState sharedManager]setTextureState2D:NO];
Im Prinzip hätte man die Texturierung nicht explizit ausschalten müssen, da wenn man mit aktivem TexturStatus rendert und keine gültige Texture mittels
glBindTexture(GL_TEXTURE_2D, _texture);gesetzt hat, OpenGL einfach das Objekt ohne Texture rendert.
Man sollte darauf achten das wenn man eine Textur erzeugt diese „Power of 2“ ist, also z.B. 128×128 oder 512×512 usw… Es gibt zwar eine Extension ARB_texture_non_power_of_two die es erlaubt Texturen in jeder Größe zu erstellen, diese wird aber nicht von allen Grafikkarten unterstützt (meiner z.B.)
OpenGL arbeitet bekanntlich wie eine Statemachine, das heisst, man setzt bestimmte Parameter durch explizites ein/ausschalten. Beispiel
glEnable(GL_TEXTURE_2D);würde die Texturierung einschalten. Was jetzt aber interessant ist, ist das OpenGL NICHT prüft ob ein Status gesetzt ist. Das bedeutet, ein Aufruf von
glEnable(GL_TEXTURE_2D); glEnable(GL_TEXTURE_2D);würde tatsächlich 2 mal ausgeführt, was natürlich Quatsch wäre und Geschwindigkeitseinbusen mit sich bringt.
Um dies Vorzubeugen gibt es 2 Möglichkeiten, entweder man holt sich einen bestimmten Zustand direkt über 1.
GLboolean glIsEnabled( GLenum cap); bzw. void glGetBooleanv( GLenum pname, GLboolean *params);Nachdem was ich bis jetzt über OpenGL gelesen habe, sollt man wann immer es möglich ist, es unterlassen, Werte auszulesen, da dies die Performance in den Keller zieht.
2. Man baut sich etwas, das eben genau diesen Status prüft und bei Bedarf updated. Genau das tut in unserem Fall die Klasse OpenGLState. Dort wird geprüft ob ein bestimmter Status gesetzt ist, wenn ja wird nichts weiter gemacht, oder eben bei Bedarf aktualisiert.
- (void)setTextureState2D:(BOOL)value { if (_textureState2D != value) { _textureState2D = value; if(value) glEnable(GL_TEXTURE_2D); else glDisable(GL_TEXTURE_2D); } }Wichtig ist nun aber, das der Status von OpenGL über diese Klasse gesetzt wird. Wenn wir einmal direkt den Status über
glEnable(GL_TEXTURE_2D);setzten und einmal über unsere Klasse, würde das ganze System durcheinader geraten und würde somit nicht mehr richtig funktionieren.
In diesem Zusammenhang, sollte man noch erwähnen, das auch eine ständige Veränderung dieser OpenGL-States vermieden werden sollte.
Ein schlechtes Beispiel wäre folgendes (Pseudo):
schlalte Licht aus rendere Text schalte Licht ein rendere Models schalte Licht aus rendere 2D Sprites schalte Licht ein rendere andere Models
Besser wäre folgendes
schlalte Licht aus rendere Text rendere 2D Sprites schalte Licht ein rendere Models rendere andere ModelsNatürlich wäre das der Idealfall, der sich aber leider nicht immer einhalten läßt. Man sollte sich deshalb vorher Gedanken machen, wann was gerendert werden soll (Hier hilft manchmal ein Blatt Papier und ein Bleistift).
Wie der Name schon sagt, wird über diese Klasse das Intro erzeugt und gerendert und zwar so lange bis der Anwender die Eingabetaste gedrückt hat. Ich habe hier noch einen kleinen Schreibmaschinen-Effekt eingebaut
//zweite Zeile SPACE INVADERS if(_lineOneIsDone && !_lineTwoIsDone) { if(counter >NEXTCHARACTERATTIME) { counter =0.0f; _charCountLineTwo++; if (_charCountLineTwo >[_lineTwo length]) { _charCountLineTwo = [_lineTwo length]; _lineTwoIsDone = YES; } } } [_introText drawTextToScreen:[_lineTwo substringToIndex:_charCountLineTwo] onXPosition:WIDTH / 2 - ( (FONTSIZE*[_lineTwo length])/2) onYPosition: 550 withColor:[NSColor colorWithCalibratedRed:1.0 green:1.0 blue:1.0 alpha:1.0]];Wer sowas mal am 64'er gemacht hat, wird sich freuen, wie einfach man es hier hat
Diese Klasse hatten wir schon weiter oben angesprochen. Was ich noch kurz erklären will ist das Event-Handling. Wie schon erwähnt, wird durch die Endlos-Schleife (while(1) ) das Standard EventHandling komplett ausgehebelt. Weshalb wir uns selbst kümmern müssen. Was so aussieht:
/** Tastatur abfragen **/ -(int)pollingKeyboard { NSEvent *event; event = [NSApp nextEventMatchingMask:NSAnyEventMask untilDate:[NSDate distantPast] inMode:NSDefaultRunLoopMode dequeue:YES]; NSEventType type = [event type]; if(type == NSKeyDown) {Im Prinzip interessiert uns nur ein KeyUp, KeyDown und ein NSWindowMovedEventType (im Windowmodus) und genau diese verarbeiten wir auch, der Rest geht an uns vorbei.
Eine kleine Unschöhnheit gibts es noch mit der Standard KeyUp bzw. KeyDown und zwar wird ja beim Halten einer Taste kurz nachdem das Event gesendet wurde, ein Aussetzter gemacht (einfach mal in einem Texteditor ne Taste gedrückt halten, dann wisst ihr was ich meine), dies ist in unserem Spiel aber nicht ewünscht, wehalb ich die beiden Hilfsvariablen:
BOOL _leftArrowIsPressed; //linke Pfeiltaste gedrückt BOOL _rightArrowIsPressed; //rechte Pfeiltaste gedrückteingebaut habe, diese werden immer dann gesetzt wenn eine Taste gedrückt/losgelassen wurde. So haben wir eine Ruckelfreie Bewegung unseres Players.
Unser Spiel arbeitet mit einer simplen 2D Rect→Rect Kollisionsprüfung, diese hat den Vorteil das sie sehr schnell ist. Allerdings manchmal etwas ungenau. Im Prinzip wird um jedes Objekt ein Rect-Bereich definiert, dieser wird dann für die Kollisionserkennung hergenommen
Dort wird einfach nur geprüft, ob beide Rechtecke überlappen, wenn ja dann lösen wir eine Kollision aus.
In unserem Beispiel wird auf folgende Kollisionen geprüft:
Zu den Shields will ich noch was anmerken, hier wird zuerst geprüft ob ein Schuss das Shield überhaupt trifft. Wenn ja wird durch die einzelnen Blocks (Bulletholes) gegangen und dort auf Kollision geprüft.
Die Klasse Shield selbst verwaltet einen Array der die Blocks enthält, dort wird dann auch diese 2. Kollisionsprüfung gemacht.
Das hat folgenden Vorteil, wenn ein Schuss nicht das Shield trifft, kann er auch nicht die Blocks getroffen
haben die im Shield liegen, somit können wir uns eine Kollisionsprüfung sparen (clever gelle
).
Im Prinzip das gleiche wie die Intro-Klasse nur eben für einen GameOver Schirm
Diese Klasse ist ürsprünglich von der OmniGroup, die es im Original erlaubt, über einen Dialog bestimmte Einstellungen zu machen (Bildschirmauflösung…) diesen Teil habe ich herraus genommen und diese Parameter in die GlobalDefinitions.h hinterlegt. Wie gesagt legt diese Klasse einen OpenGL Kontext an (Windowed Fullscreen).
Wie der Name schon sagt, kann man hierrüber 2D Text rendern. Diese Klasse ist nicht von mir Ich hab sie um das Feature erweitert, das man auch Fonts laden kann die nicht im System registriert sind.
Der TextureManager verwaltet TexturObjekte. Um ein Texture für OpenGL zu erstellen, „frägt“ man den TextureManager über
- (GLuint) textureByName:(NSString *)textureName ofType:(NSString*)imageType;nach eben dieser, dazu gibt man den Namen (es wird im App Bundle gesucht) und das Suffix an Besipiel:
_playerTexture = [[MAGTextureManager sharedManager]textureByName:@"player" ofType:@"tif"];lädt die Texture 'player.tif', bei Erfolg bekommt man einen GLuint-Wert größer 0. Sollte diese noch nicht bestehen wird ein neues TextureObjekt erzeugt und in einem Array verwaltet, gibts die Texture schon, wird sie nicht neu geladen (was ja der Sinn eines TextureManagers ist
Ich hab in Space Invaders mit tif's gearbeitet, weil diese einen Alpha-Kanal mitspeichern können, damit läßt sich sehr einfach eine transparenete Farbe bestimmen, die beim Rendern nicht angezeigt wird. Dies erreichen wir über Blending:
[[OpenGLState sharedManager]setBlending:YES]; glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
Hier wird einfach durch den ObjektManager gegangen und alle Objekte die „isDead“ sind werden gelöscht.
-(void)recycleDeadObjects { NSEnumerator *recycler = [[[GameObjectsHandler sharedManager]allObjects] objectEnumerator]; GameObject *item; while (item = [recycler nextObject]) { if([item isDead]) [[GameObjectsHandler sharedManager]removeObject: item]; } }Jetzt fragt sich vielleicht der eine oder andere, warum ich die Objekte nicht gleich lösche wenn sie getroffen wurden. Weil wir sonst auf nicht mehr existierende Objekte in unserem ObjektManager zugreifen würden, was natürlich einen Fehler verursachen würde.
ich habe den Code nochmal schön kommentiert, falls es doch Schwierigkeiten geben sollte. So, ich hoffe es hat ein bischen Spaß gemacht, für die nächsten Teile werde ich mir dann was Besonderes einfallen lassen. Ich hoffe ich hab keine Bugs vergessen. Etwaige Schreibfehler sind beabsichtigt.
Über Verbesserungsvorschläge freue ich mich immer. In diesem Sinne, viel Spaß beim coden.
wolf_10de
Was kommt als nächstes? Wie gesagt mach ich ein bischen was über OpenAL Einen Partikeleditor (brauchen wir für unser 2. Spiel) Einen Leveleditor (brauchen wir auch)
Ich habe mir vorgenommen einen Arcadeklassiker zu machen (diesmal in 3D
)
angefangene (und nie zu Ende gebrachte) 3D-Shooter gibts genügend im Internet.
Wer Lust hat mitzumachen bitte melden