Die Programmierung von intelligenten Gegnern
Ob Geister bei Pacman, Dr. Robotnik bei Sonic the Hedgehog oder Bowser bei Mario. Was wäre ein Spiel ohne einen fähigen Gegner, der dem Akteur seine Grenzen zeigt? Ohne Gegenspieler oder Bosse müsste sich das Genre unseres Spiels wohl eher an Games wie "Die Sims" oder "Harvest Moon" richten.
Da wir aber kein Spiel zum entspannen programmieren möchten, sondern den Spielern Reflexe und Geschick abverlangen, benötigen wir einen Gegner der zu unserem Genre passt. Und welche Angreifer würden wohl besser passen als Alien-Raumschiffe, die mit allen Mitteln versuchen unser Raumschiff zu Weltraum-Schrott zu verarbeiten.
Legen wir los mit dem wohl wichtigsten Teil unseres Spiels: Der künstlichen Intelligenz!
Zugegeben ist die KI, die wir erstellen werden relativ Dumm. Wir werden eine neue Klasse für das Alien erstellen und ihm beibringen zu erkennen, wo sich unser Spieler befindet. Das Alien soll entscheiden können, ob es dem Spieler folgen oder fliehen soll. Zudem soll das Alien-Raumschiff Projektile abfeuern, wenn der Spieler direkt unter ihm positioniert ist.
Nichts desto trotz ist dieser Beitrag ein guter Einstieg in die künstliche Intelligenz, die Du absolvieren solltest, wenn Du noch nie eine KI entwickelt hast.
Für das gegnerische Raumschiff habe ich ein Bild herausgesucht (passend zum Stil der Umgebung), dass Du gerne wie üblich frei verwenden darfst. Kenney.nl würde sich bestimmt über eine Verlinkung zu seiner Webseite freuen.
Wie schon für andere Objekte werden wir eine neue CSS-Klasse definieren, die das Raumschiff auf unserem Spielfeld darstellen wird. Dazu kannst Du bereits bestehende Vorlagen aus unserem CSS-Code entnehmen.
.alien { background-image: url( '/wp-content/themes/atomik_theme/scheme/post/game/games/SpaceShooter/img/enemyShip.png' ); background-repeat: no-repeat; background-size: 49px 25px; background-position: 0px 0px; width: 49px; height: 25px; position: absolute; left: 0px; top: 0px; }
Auch die Deklaration des Alien-Arrays darf zum Beginn des JavaScript-Programms nicht fehlen, um die Variable in den globalen Namespace aufzunehmen. Diese Array kannst Du ebenfalls aus einer bestehenden Vorlage, wie zum Beispiel dem Meteor einfach kopieren und ändern. Achte bei der Namensgebung nur immer darauf, dass Du genau weißt, wobei es sich bei der Variable handelt. Eine gute Benennung der Variablen hilft Dir beim Editieren des Programms zu erkennen welchen Zweck Du mit der Variable erfüllen möchtest.
// Wir platzieren unser neues Alien-Objekt in den globalen Namespace um von überall aus Zugriff zu bekommen var myAliens; [...] (function($) { $( document ).ready( function() { myInput = new Input(); myBullets = []; myMeteors = []; // Das Objekt wird wie gewohnt als Array neben den anderen deklariert myAliens = []; myPowerups = []; [...] }); })(jQuery);
Im Renderer wird nun einiges erweitert und umgeschrieben, um den Gegner auf der Oberfläche unseres Spiels platzieren zu können.
Zuerst setzen wir eine neue Abfrage zur "Destroyed"-Bedingung in der update()-Funktion, die überprüfen soll ob das Alien schon abgeschossen wurde, oder ob es noch in unserer Array munter vor sich hin schwebt.
Eine Funktion updateAliens() wird dazu verwendet die Position der Gegner pro Durchgang unseres Spiels zu erneuern und gegebenenfalls einen neuen Gegner auf das Spielfeld zu platzieren.
Wie bereits bei den Meteoren oder den Projektilen überprüfen wir in der Funktion checkDestroyed(), ob das Alien bereits das Zeitliche gesegnet hat und fügen in diesem Fall das HTML-Attribut "data-destroyed" zum Tag unserer Struktur hinzu. Da unsere CSS-Struktur alle HTML-Elemente ausblendet, wenn dieses Tag auf "true" gesetzt ist wird das Raumschiff nicht weiter angezeigt.
[...] (function($) { class Renderer { constructor() { [...] } update() { [...] // Hier wird die Bedingung der Zerstörten Objekte durch unser Alien-Objekt erweitert if( typeof myBullets !== "undefined" && myBullets.length > 0 || typeof myMeteors !== "undefined" && myMeteors.length > 0 || typeof myAliens !== "undefined" && myAliens.length > 0 ) this.checkDestroyed(); } [...] // Die Funktion updatePowerups() wird dupliziert und für die Aliens angepasst. updateAliens() { for( var i=0; i < myAliens.length; i++ ) { var $Alien = $( '#alien_'+myAliens[ i ].ID ); if( $Alien.length ) { if( myAliens[ i ].Position.Y <= Field.Height + 50 ) { $Alien.css({ 'top' : myAliens[ i ].Position.Y+'px', 'left' : myAliens[ i ].Position.X+'px' }); } } else { $( '#main_wrapper' ).append( '<div id="alien_'+myAliens[ i ].ID+'" class="alien" style="left: '+( myAliens[ i ].Position.X )+'px; top: '+( myAliens[ i ].Position.Y )+'px;"></div>' ); } } } [...] // Die Erweiterung unserer checkDestroyed-Funktion sieht ein Duplikat einer der anderen Schleifen vor. Hier muss wieder nur das entsprechende Objekt zu "myAliens" geändert werden checkDestroyed() { [...] for( var i=0; i < myAliens.length; i++ ) if( typeof myAliens[ i ] !== "undefined" ) if( typeof myAliens[ i ].Destroyed !== "undefined" && myAliens[ i ].Destroyed == true ) $( '#alien_'+myAliens[ i ].ID ).attr( 'data-destroyed', 'true' ); else if( typeof myAliens[ i ].Destroyed !== "undefined" && myAliens[ i ].Destroyed == false ) $( '#alien_'+myAliens[ i ].ID ).attr( 'data-destroyed', 'false' ); [...] } [...] // Die Kollision wird durch die Gegnerischen Projektile erweitert. Sofern kein Schild am Raumschiff installiert ist und die beiden Objekte kollidieren, erleidet das Schiff Schaden vom Projektil. collision() { [...] for( var i=0; i < myBullets.length; i++ ) if( myBullets[ i ].Type == "Alien" ) if( !myBullets[ i ].Destroyed ) if( checkCollision( this, myBullets[ i ] ) ) { var shield_exists = false; for( var j=0; j < this.PowerUps.length; j++ ) if( this.PowerUps[ j ].Type == "Shield" && this.PowerUps[ j ].Duration > 0 ) { this.PowerUps[ j ].Duration = 0; shield_exists = true; break; } if( !shield_exists ) { this.Life -= myBullets[ i ].Damage; if( this.Life <= 0 ) GameOver = true; } myBullets[ i ].Life = 0; myBullets[ i ].Destroyed = true; } [...] } } })(jQuery);
Die neue Klasse "Alien" beinhaltet wieder viele Strukturen aus unseren anderen Klassen. Der constructor() enthält die Kontroll-Variable "Destroyed", eine ID zum ansteuern vom Renderer und anderen Algorithmen, sowie Koordinaten und Maße des Raumschiffs. Damit es nicht direkt beim ersten Treffer Zerstört wird setzen wir eine zufällige Anzahl an Leben. Zudem benötigen wir den Schaden, den das Alien erteilen soll eine Geschwindigkeit und noch weitere wichtige Variablen, die Du in den Kommentaren des Quellcodes nachlesen kannst.
// Für unsere neue Klasse "Alien" können wieder einige Strukturen aus unserer Klasse "Meteor" verwenden. Um Dir Zeit beim Programmieren zu sparen kannst Du Dir die Klasse kopieren und einfach die notwendigen Änderungen vornehmen class Alien { constructor( args ) { args = args || {}; this.Destroyed = false; if( typeof args.ID !== "undefined" ) this.ID = args.ID; else this.ID = myAliens.length; this.Position = { X: 0, Y: -30, W: 49, H: 25 }; // Ich habe das Raumschiff auf eine feste Breite und Höhe definiert. Falls Du ein anderes Bild für Dein Alien verwendest solltest Du die Breite und Höhe anpassen. if( typeof args.Position !== "undefined" ) { if( typeof args.Position.X !== "undefined" ) this.Position.X = args.Position.X; if( typeof args.Position.Y !== "undefined" ) this.Position.Y = args.Position.Y; if( typeof args.Position.W !== "undefined" ) this.Position.W = args.Position.W; if( typeof args.Position.H !== "undefined" ) this.Position.H = args.Position.H; } if( this.Position.X == 0 ) { var randomPositionX = Math.floor( ( Math.random() * Field.Width ) + 1 ); this.Position.X = randomPositionX; } this.DirectionH = 0; if( typeof args.Life !== "undefined" ) this.Life = args.Life; else this.Life = Math.floor( ( Math.random() * 15 ) + 5 ); // Sofern einem neuen Objekt keine maximalen Leben beim Deklarieren übergeben wurden soll ein zufälliger Wert zwischen 5 und 20 gesetzt werden. if( typeof args.Speed !== "undefined" ) this.Speed = args.Speed; else this.Speed = Math.floor( ( Math.random() * 3 ) + 3 ); // Wie beim setzen der Leben wird hier beim fehlen der Geschwindigkeit ein zufälliger Wert zwischen 3 und 6 definiert if( typeof args.Damage !== "undefined" ) this.Damage = args.Damage; else this.Damage = 1; // Beim Schaden solltest Du es nicht übertreiben, da wir 5 Leben definiert haben. Ein "Höllen-Modus" könnte den Alien-Schiffen einen Schaden von 5 oder höher erteilen, um bei nur einem Treffer ein "GameOver" auszulösen if( typeof args.maxShootTimer !== "undefined" ) this.maxShootTimer = args.maxShootTimer; else this.maxShootTimer = Math.floor( ( Math.random() * 90 ) + 30 ); // Hier wird eine Zufällige Variable zwischen 30 und 120 definiert um einen langsamen oder schnellen Schuss-Intervall zu definieren this.shootTimer = 0; var randomPositionY = Math.floor( ( Math.random() * Field.Height/2 ) + 1 ); this.YLine = randomPositionY; this.Status = "Follow"; // Der Initial-Status des Alien-Schiffs soll auf "Follow" stehen um den Spieler zu verfolgen. In einem weiteren Teilschritt wirst Du mehr darüber erfahren } }
Ohne den Update-Prozess kann das Alien wie alle anderen Objekte keine Aktionen ausführen. Die Funktion update() wird bereits in der Hauptschleife aufgerufen. Als nächstes sollten wir uns darum kümmern diese Funktion zu füllen.
Die erste Bedingung sollte eine Prüfung der Kontroll-Variable "Destroyed" sein, um die Funktion direkt abzubrechen, sofern das Schiff bereits zerstört wurde. Dadurch sparen wir uns wertvolle Performance.
Sofern das Alien-Schiff nicht zerstört wurde soll es unserem Algorithmus zur Zerstörung unseres Raumschiffs folgen. Dazu splitten wir den Code in die Funktionen move() und shoot(), die wir als Erstes Element innerhalb der Destroyed-Bedingung aufrufen. Nach diesen beiden Funktionen erstellen wir einen weiteren Algorithmus, der das Alien je nach "Bedarf" dem Spieler folgen oder flüchten soll. Damit das gegnerische Objekt ein wenig mehr Dynamik erhält soll die Y-Koordinate zufällig geändert werden.
class Alien { [...] update() { // Eine Bedingung soll prüfen, ob das Raumschiff nicht zerstört wurde und der Spiel-Status nicht "GameOver" ist if( !GameOver && !this.Destroyed ) { this.move(); this.shoot(); // Durch einen Timer können wir dem Alien-Schiff beibringen, dass er nur schießen darf, wenn der Timer auf "0" steht if( this.shootTimer > 0 ) this.shootTimer--; // Das Alien Raumschiff soll sich mit einer 1%igen Wahrscheinlichkeit nach oben, bzw. weiter nach unten platzieren var randomChangeY = Math.floor( ( Math.random() * 100 ) + 1 ); if( randomChangeY <= 1 ) { var randomPositionY = Math.floor( ( Math.random() * Field.Height/2 ) + 1 ); this.YLine = randomPositionY; } // Hier definieren wir eine variable Wahrscheinlichkeit. Das Ziel ist es, dass das Alien mehr zum Spieler fixiert ist. Es soll dennoch mit einer geringeren Wahrscheinlichkeit fliehen können var maxRand = 2000; if( this.Status == "Follow" ) maxRand = 3000; var randomChangeStatus = Math.floor( ( Math.random() * maxRand ) + 1 ); if( randomChangeStatus <= 5 ) { if( this.Status == "Escape" ) this.Status = "Follow"; else if( this.Status == "Follow" ) this.Status = "Escape"; } } } }
In der Funktion move() benötigen wir einmal den Status des Aliens (Flucht oder Folgen) und addieren oder subtrahieren die Geschwindigkeit je nach Status zur X-Koordinate des Gegners.
Da sich das Alien nicht außerhalb des Spielfelds bewegen soll, benötigen wir eine Bedingung, die die X-Koordinate ab einem bestimmten Punkt erfassen soll. Sofern diese Bedingung zutrifft soll die Geschwindigkeit nicht mehr zur Position addiert bzw. subtrahiert werden.
class Alien { [...] move() { // Falls sich das Alien-Schiff rechts vom Spieler befindet, soll es bei einer Flucht weiter nach rechts steuern. Beim Folgen soll es nach rechts steuern, wenn das Schiff sich links vom Spieler befindet if( ( this.Status == "Follow" && ( mySpaceship.Position.X ) > ( this.Position.X + this.Position.W/2 ) ) || ( this.Status == "Escape" && ( mySpaceship.Position.X + mySpaceship.Position.W/2 ) < ( this.Position.X + this.Position.W/2 ) ) ) { if( this.Position.X + this.Position.W < Field.Width ) this.Position.X += this.Speed; } // Genaues Gegenteil von Oben: Flucht (Links vom Schiff) -> nach links bewegen; Folgen (Rechts vom Schiff) -> nach links bewegen else if( ( this.Status == "Follow" && ( mySpaceship.Position.X + mySpaceship.Position.W ) < ( this.Position.X + this.Position.W/2 ) ) || ( this.Status == "Escape" && ( mySpaceship.Position.X + mySpaceship.Position.W/2 ) > ( this.Position.X + this.Position.W/2 ) ) ) { if( this.Position.X > 0 ) this.Position.X -= this.Speed; } // Sofern sich das Schiff nicht etwa (+-5 Pixel Abweichung) auf der Y-Koordinate befindet, die in der Funktion "update" gesetzt wurde, wird es entsprechend nach oben oder unten gesteuert if( this.Position.Y + 5 < this.YLine ) { this.Position.Y += this.Speed; } else if( this.Position.Y - 5 > this.YLine ) { this.Position.Y -= this.Speed; } } }
Die Funktion shoot() soll dem Alien ermöglichen den Spieler mit Projektilen zu befeuern. Durch die Variable "randomShoot" stellen wir sicher, dass das Alien-Schiff nicht kontinuierlich feuert, sondern nur mit einer 5%-igen Wahrscheinlichkeit. Zudem muss die Abkling-Zeit "shootTimer" auf 0 abgelaufen sein.
Sofern ein Projektil im negativen Bereich (also außerhalb der sichtbaren Bereichs) liegt, wird dieses mit den neuen Daten befüllt. Sozusagen recyclen wir Projektile, die nicht mehr verwendet werden. Dadurch sparen wir uns zusätzlich Ressourcen und Performance.
class Alien { [...] shoot() { // Damit das Raumschiff nicht ununterbrochen Feuert soll eine zufällige Variable steuern, ob ein Schuss erfolgen wird oder nicht. var randomShoot = Math.floor( ( Math.random() * 100 ) + 1 ); // Hier definiere ich eine 5%ige Chance, dass ein Projektil abgefeuert werden soll. Du kannst den Wert gerne bearbeiten, um eine höhere oder niedrigere Chance zu setzen, dass das Alien feuert. if( randomShoot <= 5 ) { // Den shootTimer sollten wir auch beachten, damit das Alien-Raumschiff sich auch an ein paar Restriktionen hält. Du kannst die Bedingung auch löschen, wenn du keinen Timer für Deine Aliens möchtest. if( this.shootTimer <= 0 ) { var bulletPosition = { X: this.Position.X + this.Position.W / 2, Y: this.Position.Y + this.Position.H }; // Folgende Kontroll-Variable soll uns dabei helfen herauszufinden, ob bereits ein Projektil außerhalb des sichtbaren Bereichs liegt. Ist das der Fall, überschreibt das neue Projektil das nicht-sichtbare, andernfalls wird ein neues erstellt. var bullet_on_negative = false; for( var i=0; i < myBullets.length; i++ ) if( myBullets[ i ].Position.Y < -20 ) { $( '#bullet_'+myBullets[ i ].ID ).remove(); myBullets[ i ] = new Bullet({ ID: i, Type: "Alien", Position: bulletPosition }); bullet_on_negative = true; break; } if( !bullet_on_negative ) myBullets.push( new Bullet({ Type: "Alien", Position: bulletPosition }) ); // Nachdem ein Projekt abgefeuert wurde soll sich der Timer wieder zurücksetzen. Andernfalls bringt uns der Timer gar nichts. this.shootTimer = this.maxShootTimer; } } } }
Ohne einen passenden Algorithmus werden die Aliens nie erstellt. Da wir bereits einen ähnlichen Code mit den Meteoriten und den Projektilen besitzen kann dieser einfach dupliziert und angepasst werden.
(function($) { [...] function mainLoop( timestamp ) { [...] // Der Zufall soll sich mit jedem neuen Punkt, den der Spieler bekommt ein wenig erweitern. "500" soll der kleinste Wert sein, der die Variable annehmen kann var randNewAlien = Math.floor( ( Math.random() * Math.max( 500, ( 3000 - Score*0.1 ) ) ) + 1 ); if( randNewAlien <= 1 ) { // Sofern die Zufalls-Variable "1" beträgt soll ein neues, gegnerisches Raumschiff erstellt werden newAlien(); } // Wie bei den anderen Objekten wird die Funktion "update" von jedem Alien-Schiff aufgerufen for( var i=0; i < myAliens.length; i++ ) myAliens[ i ].update(); [...] } function newAlien() { // Hier verwenden wir den gleichen Algorithmus wie beim Projektil, um das Alien-Raumschiff zu erstellen var destroyed_aliens_exists = false; for( var i=0; i < myAliens.length; i++ ) if( myAliens[ i ].Destroyed ) { myAliens[ i ] = new Alien({ ID: myAliens[ i ].ID }); destroyed_aliens_exists = true; break; } if( !destroyed_aliens_exists ) myAliens.push( new Alien() ); } [...] })(jQuery);
Die gegnerischen Raumschiffe sollten nun auf unserem Spielfeld erscheinen. Damit der Spieler getroffen wird und die Projektile in die richtige Richtung steuern müssen wir noch die Klasse Bullet ein wenig erweitern.
Das feindliche Projektil soll keine Meteoriten treffen können. Dieses Verhalten können wir einpflegen indem wir eine Überprüfung in der Funktion collision() hinterlegen, die kontrolliert ob das Projektil vom Typ "Player" ist. Alle anderen Typen fliegen einfach durch die Meteoriten hindurch.
[...] (function($) { [...] class Bullet { constructor( args ) { [...] // Sofern dem neuen Objekt kein Typ übergeben wurde, soll der Standard "Player" sein, da unser Raumschiff immer existiert (Ausgenommen bei "GameOver"). Andernfalls wird der übergebene Typ gesetzt (Also auch "Alien") if( typeof args.Type !== "undefined" ) this.Type = args.Type; else this.Type = 'Player'; if( typeof args.Damage !== "undefined" ) this.Damage = args.Damage; else if( this.Type == "Player" ) { this.Damage = 1; var damageMultiplier = 1; for( var i=0; i < mySpaceship.PowerUps.length; i++ ) { if( mySpaceship.PowerUps[i].Duration > 0 && mySpaceship.PowerUps[i].Type == "Power" ) damageMultiplier += mySpaceship.PowerUps[i].Multiplier; } if( damageMultiplier > 1 ) { this.Damage *= damageMultiplier; this.PowerupShot = true; } } else this.Damage = 1; [...] // Das Projektil soll in nachfolgendem Snippet zum Main-Wrapper hinzugefügt werden. Hier wird nun auch zwischen "Player" und "Alien" unterschieden if( this.Position.X != -100 && this.Position.Y != -100 ) { if( this.Type == "Player" ) { $( '#main_wrapper' ).append( '<div class="bullet_shot" data-powerup="'+this.PowerupShot+'" data-type="'+this.Type.toLowerCase()+'" style="left: '+( this.Position.X - 13 )+'px; top: '+( this.Position.Y - 13 )+'px;"></div>' ); $( '#main_wrapper .bullet_shot:last-of-type' ).delay( 1000 ).queue( function() { $(this).remove(); } ); } else if( this.Type == "Alien" ) { $( '#main_wrapper' ).append( '<div class="bullet_shot" data-powerup="'+this.PowerupShot+'" data-type="'+this.Type.toLowerCase()+'" style="left: '+( this.Position.X - 13 )+'px; top: '+( this.Position.Y - 13 )+'px;"></div>' ); $( '#main_wrapper .bullet_shot:last-of-type' ).delay( 1000 ).queue( function() { $(this).remove(); } ); } } } update() { [...] } move() { // Die Funktion wird erweitert, damit Projektile, die vom Alien-Raumschiff abgefeuert wurden in die entgegengesetzte Richtung (Zum Spieler) navigieren kann if( this.Type == "Player" ) this.Position.Y -= this.Speed; else if( this.Type == "Alien" ) this.Position.Y += this.Speed; } collision() { if( !this.Destroyed ) { for( var i=0; i < myMeteors.length; i++ ) // Die neue Bedingung wird gesetzt um ein Alien-Projektil an einem Meteor vorbeiziehen zu lassen if( this.Type == "Player" ) if( !myMeteors[ i ].Destroyed ) if( checkCollision( this, myMeteors[ i ] ) ) { myBullets[ this.ID ].Destroyed = true; myMeteors[ i ].Life -= this.Damage; if( myMeteors[ i ].Life <= 0 ) { if( myMeteors[ i ].Type == "Big" ) Score += 100; else Score += 25; myMeteors[ i ].Destroyed = true; newPowerup( i ); } } // Für folgenden Code-Block duplizieren wir die Kollisions-Abfrage des Meteors und passen Sie an, damit sie für unser Alien-Raumschiff gilt for( var i=0; i < myAliens.length; i++ ) if( this.Type == "Player" ) if( !myAliens[ i ].Destroyed ) if( checkCollision( this, myAliens[ i ] ) ) { myBullets[ this.ID ].Destroyed = true; myAliens[ i ].Life -= this.Damage; if( myAliens[ i ].Life <= 0 ) { // Der Score soll natürlich wesentlich höher sein, als bei Meteore Score += 250; myAliens[ i ].Destroyed = true; } } } } } })(jQuery);
Nachdem Du diese Änderungen an Deinem Spiel vorgenommen hast sind wir nun an unser Ziel angelangt. Ab diesem Punkt hast Du ein vollwertiges SpaceShooter-Minispiel, dass Du nach Belieben Erweitern und Pflegen kannst.
Ich hoffe, dass Dir das Programmieren Spaß gemacht hat und das Du Dein eigenes Spiel erfolgreich programmiert hast.
Du hast Fragen oder Wünsche? Hinterlasse einen Kommentar oder kontaktiere mich über das Kontaktformular und ich helfe Dir gerne nach Möglichkeit weiter.
Ich freue mich darauf dein eigenes SpaceShooter Spiel zu sehen, dass Du programmiert hast.
Teil 10: Künstliche Intelligenz in Spielen entwickeln
Das konnte dich auch interessieren
Kommentare