Das Organisieren Ihres Spielcodes in komponentenbasierte Entitäten, anstatt sich nur auf die Klassenvererbung zu verlassen, ist ein beliebter Ansatz in der Spieleentwicklung. In diesem Lernprogramm werden wir untersuchen, warum Sie dies tun könnten, und eine einfache Game-Engine mit dieser Technik einrichten.
In diesem Lernprogramm werde ich auf Komponenten basierende Spielentitäten untersuchen, nachsehen, warum Sie diese verwenden möchten, und einen pragmatischen Ansatz vorschlagen, um Ihren Zeh in das Wasser zu tauchen.
Da es sich um eine Geschichte über Codeorganisation und -architektur handelt, fange ich an, den üblichen Haftungsausschluss "Raus aus dem Gefängnis" zu nehmen. aber es könnte für Sie arbeiten. Persönlich möchte ich so viele Ansätze wie möglich herausfinden und dann herausfinden, was zu mir passt.
In diesem zweiteiligen Tutorial erstellen wir dieses Asteroids-Spiel. (Den vollständigen Quellcode finden Sie auf GitHub.) In diesem ersten Teil konzentrieren wir uns auf die Kernkonzepte und die allgemeine Game Engine.
In einem Spiel wie Asteroids könnten wir einige grundlegende Arten von "Ding" auf dem Bildschirm haben: Kugeln, Asteroiden, Spielerschiffe und feindliche Schiffe. Wir möchten diese Basistypen als vier separate Klassen darstellen, von denen jede den gesamten Code enthält, den wir zum Zeichnen, Animieren, Bewegen und Steuern dieses Objekts benötigen.
Während dies funktionieren wird, ist es möglicherweise besser, dem zu folgen Wiederholen Sie sich nicht (DRY) Prinzip und versuchen, einen Teil des Codes zwischen den einzelnen Klassen wiederzuverwenden - schließlich wird der Code zum Verschieben und Zeichnen eines Aufzählungszeichens dem Code zum Verschieben und Zeichnen eines Codes sehr ähnlich sein, wenn nicht genau dem Code Asteroid oder ein Schiff.
So können wir unsere Rendering- und Bewegungsfunktionen in eine Basisklasse umwandeln, von der sich alles erstreckt. Aber Schiff
und EnemyShip
müssen auch schießen können. An dieser Stelle könnten wir das hinzufügen schießen
Funktionieren Sie zur Basisklasse und erstellen Sie eine "Giant Blob" -Klasse, die im Grunde alles kann, und stellen Sie sicher, dass Asteroiden und Aufzählungszeichen niemals ihre nennen schießen
Funktion. Diese Basisklasse würde bald sehr groß werden und anschwellen, wenn Entitäten neue Dinge tun müssen. Das ist nicht unbedingt falsch, aber ich finde kleinere, spezialisiertere Klassen, die einfacher zu warten sind.
Alternativ können wir die Wurzel tiefer Vererbung gehen und so etwas haben EnemyShip erweitert Ship erweitert ShootingEntity erweitert Entity
. Auch dieser Ansatz ist nicht falsch und funktioniert auch recht gut. Wenn Sie jedoch weitere Arten von Entitäten hinzufügen, müssen Sie die Vererbungshierarchie ständig neu anpassen, um alle möglichen Szenarien zu behandeln, und Sie können sich in eine Ecke begeben wo ein neuer Entity-Typ die Funktionalität von zwei verschiedenen Basisklassen haben muss, die mehrfache Vererbung erfordern (was die meisten Programmiersprachen nicht bieten).
Ich habe den Deep-Hierarchie-Ansatz oft selbst verwendet, aber ich bevorzuge den Giant Blob-Ansatz, da zumindest dann alle Entitäten eine gemeinsame Schnittstelle haben und neue Entitäten leichter hinzugefügt werden können. !)
Es gibt jedoch einen dritten Weg…
Wenn wir uns das Asteroiden-Problem in Bezug auf Dinge vorstellen, die Objekte möglicherweise tun müssen, erhalten wir möglicherweise eine Liste wie diese:
Bewegung()
schießen()
takeDamage ()
sterben()
machen()
Anstatt eine komplizierte Vererbungshierarchie auszuarbeiten, für die Objekte welche Aufgaben ausführen können, lassen Sie uns das Problem in Bezug auf modellieren Komponenten, die diese Aktionen ausführen können.
Zum Beispiel könnten wir eine erstellen Gesundheit
Klasse mit den Methoden takeDamage ()
, heilen()
und sterben()
. Dann kann jedes Objekt, das Schaden nehmen und sterben muss, eine Instanz des erstellen Gesundheit
class - wobei "compose" im Wesentlichen "einen Verweis auf die eigene Instanz dieser Klasse beibehalten" bedeutet.
Wir könnten eine andere Klasse erstellen Aussicht
um die Renderfunktionalität zu erhalten, rief man an Karosserie
um mit der Bewegung umzugehen, rief man an Waffe
Schießen zu handhaben.
Die meisten Entity-Systeme basieren auf dem oben beschriebenen Prinzip, unterscheiden sich jedoch darin, wie Sie auf die in einer Komponente enthaltenen Funktionen zugreifen.
Ein Ansatz besteht zum Beispiel darin, die API jeder Komponente in der Entität zu spiegeln, sodass eine Entität, die Schaden erleiden könnte, eine takeDamage ()
Funktion das selbst ruft einfach das an takeDamage ()
Funktion seiner Gesundheit
Komponente.
class Entity private var _health: Gesundheit; //… anderer Code… // öffentliche Funktion takeDamage (dmg: int) _health.takeDamage (dmg);
Sie müssen dann eine Schnittstelle erstellen, die so etwas heißt IHealth
für Ihre Entität zu implementieren, damit andere Objekte auf das Objekt zugreifen können takeDamage ()
Funktion. So können Sie in einem Java-OOP-Handbuch dazu aufgefordert werden.
getComponent ()
Ein anderer Ansatz besteht darin, jede Komponente einfach in einer Schlüsselwertsuche zu speichern, so dass jede Entität eine Funktion hat, die so etwas wie genannt wird getComponent ("Komponentenname")
was eine Referenz auf die bestimmte Komponente zurückgibt. Sie müssen dann die Referenz, die Sie erhalten, auf den Komponententyp umwandeln, den Sie möchten - etwa:
var health: Gesundheit = Gesundheit (getComponent ("Gesundheit"));
Im Grunde funktioniert das Entity / Behavior-System von Unity. Es ist sehr flexibel, da Sie ständig neue Komponententypen hinzufügen können, ohne die Basisklasse zu ändern oder neue Unterklassen oder Schnittstellen zu erstellen. Es kann auch nützlich sein, wenn Sie Konfigurationsdateien verwenden möchten, um Entitäten zu erstellen, ohne Ihren Code erneut zu kompilieren. Ich überlasse es jedoch einer anderen Person, um es herauszufinden.
Ich bevorzuge, dass alle Entitäten über eine öffentliche Eigenschaft für jeden Hauptkomponententyp verfügen und die Felder leer lassen, wenn die Entität diese Funktionalität nicht hat. Wenn Sie eine bestimmte Methode aufrufen möchten, müssen Sie nur die Entität "erreichen", um die Komponente mit dieser Funktionalität zu erhalten - beispielsweise den Aufruf Feind.health.takeDamage (5)
einen Feind angreifen.
Wenn Sie versuchen anzurufen health.takeDamage ()
auf eine Entität, die keine hat Gesundheit
Komponente wird es kompilieren, aber Sie erhalten einen Laufzeitfehler, der Sie darüber informiert, dass Sie etwas Dummes getan haben. In der Praxis geschieht dies selten, da es ziemlich offensichtlich ist, welche Entitätstypen welche Komponenten haben (z. B. hat ein Baum natürlich keine Waffe!).
Einige strenge Befürworter der OOP könnten argumentieren, dass mein Ansatz einige OOP-Prinzipien verletzt, aber ich finde, dass es wirklich gut funktioniert, und es gibt einen wirklich guten Präzedenzfall aus der Geschichte von Adobe Flash.
In ActionScript 2 die Filmausschnitt
Klasse hatte Methoden zum Zeichnen von Vektorgrafiken: Sie könnten zum Beispiel aufrufen myMovieClip.lineTo ()
eine Linie ziehen In ActionScript 3 wurden diese Zeichenmethoden in den Ordner verschoben Grafik
Klasse und jeder Filmausschnitt
bekommt ein Grafik
Komponente, auf die Sie zum Beispiel durch Aufruf zugreifen, myMovieClip.graphics.lineTo ()
auf die gleiche Weise, die ich für beschrieben habe enemy.health.takeDamage ()
. Wenn es für die Entwickler von ActionScript-Sprachen gut genug ist, ist es für mich gut genug.
Im Folgenden werde ich eine sehr vereinfachte Version des Systems beschreiben, das ich in all meinen Spielen verwende. In Bezug auf die Vereinfachung sind das etwa 300 Zeilen Code, im Vergleich zu 6.000 für meinen vollen Motor. Aber nur mit diesen 300 Zeilen können wir viel erreichen!
Ich habe gerade genug Funktionalität übrig, um ein funktionierendes Spiel zu erstellen, während der Code so kurz wie möglich gehalten wird, damit er einfacher zu folgen ist. Der Code ist in ActionScript 3 enthalten, eine ähnliche Struktur ist jedoch in den meisten Sprachen möglich. Es gibt einige öffentliche Variablen, bei denen es sich um Eigenschaften handeln könnte (d. H. Sie werden hinterlegt erhalten
und einstellen
Accessor-Funktionen), aber da dies in ActionScript recht ausführlich ist, habe ich sie zur besseren Lesbarkeit als öffentliche Variablen gelassen.
IEntity
SchnittstelleBeginnen wir mit der Definition einer Schnittstelle, die von allen Entitäten implementiert wird:
Paket-Engine import org.osflash.signals.Signal; / ** *… * @author Iain Lobb - [email protected] * / public interface IEntity // ACTIONS-Funktion destroy (): void; Funktions-Update (): ungültig; function render (): void; // COMPONENTS function get body (): Körper; Funktionssatzkörper (Wert: Körper): ungültig; Funktion erhalten Physik (): Physik; Funktionssatz Physik (Wert: Physik): void function get health (): Gesundheitsfunktion set health (value: Health): void function get weapon (): Waffe; Funktionssatz Waffe (Wert: Waffe): ungültig; Funktion get view (): Ansicht; Funktionssatzansicht (Wert: Ansicht): ungültig; // SIGNALS-Funktion get entityCreated (): Signal; Funktionssatz entityCreated (Wert: Signal): void; Funktion zerstört (): Signal; Funktionssatz zerstört (Wert: Signal): ungültig; // DEPENDENCIES-Funktion get target (): Vektor.; Funktionssatzziele (Wert: Vektor. ):Leere; Funktion get group (): Vektor. ; Funktionssatzgruppe (Wert: Vektor. ):Leere;
Alle Entitäten können drei Aktionen ausführen: Sie können sie aktualisieren, rendern und zerstören.
Sie haben jeweils "Steckplätze" für fünf Komponenten:
Karosserie
, Handlingposition und -größe.Physik
, Bewegung zu handhaben.Gesundheit
, Umgang mit verletzen.Waffe
, Umgang mit angreifen.Aussicht
, So können Sie die Entität rendern.Alle diese Komponenten sind optional und können leer bleiben, aber in der Praxis werden die meisten Entitäten mindestens einige Komponenten enthalten.
Eine statische Szenerie, mit der der Spieler nicht interagieren kann (z. B. ein Baum), würde nur einen Körper und eine Ansicht benötigen. Es würde keine Physik brauchen, da es sich nicht bewegt, es würde keine Gesundheit brauchen, da man es nicht angreifen kann, und es würde sicherlich keine Waffe brauchen. Das Schiff des Spielers in Asteroids würde dagegen alle fünf Komponenten benötigen, da es sich bewegen, schießen und verletzen kann.
Durch Konfigurieren dieser fünf Grundkomponenten können Sie die meisten einfachen Objekte erstellen, die Sie möglicherweise benötigen. Manchmal reichen sie jedoch nicht aus, und an diesem Punkt können wir entweder die Basiskomponenten erweitern oder neue zusätzliche Komponenten erstellen - beides werden wir später besprechen.
Als nächstes haben wir zwei Signale: entityCreated
und zerstört
.
Signale sind eine Open-Source-Alternative zu nativen ActionScript-Ereignissen, die von Robert Penner erstellt wurden. Sie sind wirklich nett zu verwenden, da sie es Ihnen ermöglichen, Daten zwischen dem Dispatcher und dem Listener zu übergeben, ohne viele benutzerdefinierte Ereignisklassen erstellen zu müssen. Weitere Informationen zur Verwendung finden Sie in der Dokumentation.
Das entityCreated
Signal ermöglicht einer Entität, dem Spiel mitzuteilen, dass eine weitere neue Entität hinzugefügt werden muss - ein klassisches Beispiel, wenn eine Waffe eine Kugel erzeugt. Das zerstört
Signal informiert das Spiel (und alle anderen hörenden Objekte) darüber, dass diese Entität zerstört wurde.
Schließlich hat die Entität zwei weitere optionale Abhängigkeiten: Ziele
, Dies ist eine Liste von Entitäten, die möglicherweise angegriffen werden sollen, und Gruppe
, Dies ist eine Liste der Entitäten, zu denen es gehört. Beispielsweise kann ein Spielerschiff eine Liste von Zielen haben, die alle Feinde im Spiel wären, und zu einer Gruppe gehören, die auch andere Spieler und befreundete Einheiten enthält.
Entität
KlasseNun schauen wir uns das an Entität
Klasse, die diese Schnittstelle implementiert.
Paket-Engine import org.osflash.signals.Signal; / ** *… * @author Iain Lobb - [email protected] * / public class Entity implementiert IEntity private var _body: Body; private var _physics: Physik; privat var _health: Gesundheit; private var _weapon: Waffe; private var _view: Ansicht; private var _entityCreated: Signal; private var _destroyed: Signal; private var _targets: Vektor.; private var _group: Vektor. ; / * * Alles, was in deinem Spiel existiert, ist eine Entität! * / public function Entity () entityCreated = neues Signal (Entity); zerstört = neues Signal (Entity); public function destroy (): void zerstört.dispatch (this); if (group) group.splice (group.indexOf (this), 1); public function update (): void if (physics) physics.update (); public function render (): void if (view) view.render (); public function get body (): body return _body; public function set body (value: body): void _body = value; public function get physics (): Physik Rückgabe _Physik; public function set physics (Wert: Physik): void _physics = value; public function get health (): Gesundheit return _health; public function set health (Wert: Gesundheit): void _health = value; public function get weapon (): Waffe return _weapon; public function set weapon (Wert: Waffe): void _weapon = value; öffentliche Funktion get view (): View return _view; Öffentliche Funktionssatzansicht (Wert: Ansicht): void _view = value; public function get entityCreated (): Signal return _entityCreated; public function set entityCreated (value: Signal): void _entityCreated = value; public function get destroy (): Signal return _destroyed; Öffentlicher Funktionssatz zerstört (Wert: Signal): void _destroyed = Wert; Funktion public get get (): Vektor. return _targets; Öffentliche Funktionssatzziele (Wert: Vektor). ): void _targets = value; public function get group (): Vektor. return _group; Öffentliche Funktionssatzgruppe (Wert: Vektor. ): ungültig _group = Wert;
Es sieht lang aus, aber die meisten davon sind nur die ausführlichen Getter- und Setter-Funktionen (boo!). Der wichtigste Aspekt, den Sie betrachten müssen, sind die ersten vier Funktionen: der Konstruktor, in dem wir unsere Signale erstellen; zerstören()
, wo wir das zerstörte Signal versenden und die Entität aus ihrer Gruppenliste entfernen; aktualisieren()
, Dort aktualisieren wir alle Komponenten, die für jede Spielschleife erforderlich sind. In diesem einfachen Beispiel ist dies jedoch nur das Physik
Komponente - und schließlich machen()
, wo wir der Ansicht sagen, was sie tun soll.
Sie werden feststellen, dass die Komponenten hier in der Entity-Klasse nicht automatisch instanziiert werden. Dies ist darauf zurückzuführen, dass jede Komponente optional ist.
Lassen Sie uns nun die Komponenten einzeln betrachten. Zuerst die Körperkomponente:
Paket-Engine / ** *… * @author Iain Lobb - [email protected] * / public class Körper public var entity: Entität; public var x: Anzahl = 0; public var y: Anzahl = 0; öffentlicher Var Winkel: Anzahl = 0; public var radius: Anzahl = 10; / * * Wenn du einer Entität einen Körper gibst, kann sie in der Welt eine physische Form annehmen. Um sie zu sehen, brauchst du jedoch eine Ansicht. * / public function Körper (Entität: Entität) this.entity = Entität; public function testCollision (otherEntity: Entity): Boolean var dx: Number; var dy: Anzahl; dx = x - otherEntity.body.x; dy = y - otherEntity.body.y; return Math.sqrt ((dx * dx) + (dy * dy)) <= radius + otherEntity.body.radius;
Alle unsere Komponenten benötigen einen Verweis auf ihre Eigentümerinstanz, die wir an den Konstruktor übergeben. Der Körper hat dann vier einfache Felder: eine X- und Y-Position, einen Drehwinkel und einen Radius, um seine Größe zu speichern. (In diesem einfachen Beispiel sind alle Objekte kreisförmig!)
Diese Komponente hat auch eine einzige Methode: testCollision ()
, Dies verwendet Pythagoras zur Berechnung der Entfernung zwischen zwei Entitäten und vergleicht diese mit ihren kombinierten Radien. (Mehr Infos hier.)
Als nächstes schauen wir uns das an Physik
Komponente:
Paket-Engine / ** *… * @author Iain Lobb - [email protected] * / public class Physik public var entity: Entity; public var drag: Number = 1; public var VelocityX: Anzahl = 0; public var VelocityY: Anzahl = 0; / * * Stellt einen grundlegenden physischen Schritt ohne Kollisionserkennung bereit. * Erweitern, um Kollisionsbehandlung hinzuzufügen. * / public function Physik (Entität: Entität) this.entity = Entität; public function update (): void entity.body.x + = VelocityX; entity.body.y + = VelocityY; VelocityX * = Ziehen; Geschwindigkeit y * = ziehen; public function thrust (power: Number): void VelocityX + = Math.sin (-entity.body.angle) * power; VelocityY + = Math.cos (-entity.body.angle) * Leistung;
Mit Blick auf die aktualisieren()
Funktion können Sie sehen, dass die VelocityX
und Geschwindigkeit y
Die Werte werden an die Position der Entität addiert, die sie bewegt, und die Geschwindigkeit wird mit multipliziert ziehen
, was bewirkt, dass das Objekt allmählich abgebremst wird. Das Schub()
Diese Funktion ermöglicht eine schnelle Beschleunigung der Entität in die Richtung, in die sie zeigt.
Als nächstes schauen wir uns das an Gesundheit
Komponente:
Paket-Engine import org.osflash.signals.Signal; / ** *… * @author Iain Lobb - [email protected] * / public class Health public var entity: Entität; öffentliche var Treffer: int; public var starb: Signal; public var verletzt: Signal; öffentliche Funktion Gesundheit (Entität: Entität) this.entity = Entität; gestorben = neues Signal (Entität); verletzt = neues Signal (Entity); public function hit (Schaden: int): void Schläge - = Schaden; hurt.dispatch (entität); wenn (trifft < 0) died.dispatch(entity);
Das Gesundheit
Komponente hat eine Funktion aufgerufen schlagen()
, die Entität verletzt werden. Wenn dies geschieht, wird die trifft
Der Wert wird reduziert, und alle abhörenden Objekte werden durch das Senden der Benachrichtigung benachrichtigt verletzt
Signal. Ob trifft
kleiner als Null ist, ist die Entität tot und wir versenden die ist gestorben
Signal.
Mal sehen was drin ist Waffe
Komponente:
Paket-Engine import org.osflash.signals.Signal; / ** *… * @author Iain Lobb - [email protected] * / public class Waffe public var entity: Entity; öffentliche var ammo: int; / * * Waffe ist die Basisklasse für alle Waffen. * / public function Waffe (Entität: Entität) this.entity = Entität; public function fire (): void ammo--;
Nicht viel hier Das ist so, weil dies wirklich nur eine Basisklasse für die eigentlichen Waffen ist - wie Sie in der sehen werden Gewehr
Beispiel später. Da ist ein Feuer()
Methode, die Unterklassen überschreiben sollten, aber hier wird nur der Wert von reduziert Munition
.
Die letzte zu überprüfende Komponente ist Aussicht
:
Paket-Engine import flash.display.Sprite; / ** *… * @author Iain Lobb - [email protected] * / public class View public var entity: Entität; öffentliche Var-Skala: Anzahl = 1; public var alpha: Number = 1; öffentliches var Sprite: Sprite; / * * View ist eine Anzeigekomponente, die ein Objekt anhand der Standardanzeigeliste rendert. * / public function View (Entität: Entität) this.entity = Entität; public function render (): void sprite.x = entity.body.x; sprite.y = entity.body.y; sprite.rotation = entity.body.angle * (180 / Math.PI); Sprite α = Alpha; sprite.scaleX = Maßstab; sprite.scaleY = Maßstab;
Diese Komponente ist sehr spezifisch für Flash. Das Hauptereignis hier ist das machen()
Diese Funktion aktualisiert ein Flash-Sprite mit den Körper- und Rotationswerten sowie den Alpha- und Skalierungswerten, die es selbst speichert. Wenn Sie ein anderes Wiedergabesystem verwenden möchten, z copyPixels
Blitting oder Stage3D (oder tatsächlich ein System, das für eine andere Plattformwahl relevant ist), würden Sie diese Klasse anpassen.
Spiel
KlasseJetzt wissen wir, wie eine Entität und alle ihre Komponenten aussehen. Bevor wir mit dieser Engine ein Beispielspiel erstellen, wollen wir uns das letzte Stück der Engine ansehen - die Game-Klasse, die das gesamte System steuert:
Paket-Engine import flash.display.Sprite; import flash.display.Stage; import flash.events.Event; / ** *… * @author Iain Lobb - [email protected] * / public class Spiel erweitert Sprite public var entity: Vector.= neuer Vektor. (); public var isPaused: Boolean; statische öffentliche var stage: stage; / * * Spiel ist die Basisklasse für Spiele. * / public function Game () addEventListener (Event.ENTER_FRAME, onEnterFrame); addEventListener (Event.ADDED_TO_STAGE, onAddedToStage); protected function onEnterFrame (Ereignis: Ereignis): void if (isPaused) return; aktualisieren(); machen(); protected function update (): void for each (Entität Entität in Entitäten) entity.update (); protected function render (): void for each (Entität Entität in Entitäten) entity.render (); Geschützte Funktion onAddedToStage (Ereignis: Ereignis): void Game.stage = stage; Spiel beginnen(); Geschützte Funktion startGame (): void Geschützte Funktion stopGame (): void für jedes (Entität Entität in Entitäten) if (entity.view) removeChild (entity.view.sprite); Entitäten.Länge = 0; public function addEntity (entity: Entity): Entity entitäten.push (entity); entity.destroyed.add (onEntityDestroyed); entity.entityCreated.add (addEntity); if (entity.view) addChild (entity.view.sprite); Entität zurückgeben; Geschützte Funktion onEntityDestroyed (Entität: Entität): void Entities.splice (Entitäten.indexOf (Entität), 1); if (entity.view) removeChild (entity.view.sprite); entity.destroyed.remove (onEntityDestroyed);
Es gibt viele Details zur Implementierung, aber lassen Sie uns die Highlights herausgreifen.
Jeder Frame, der Spiel
class durchläuft alle Entitäten und ruft ihre Aktualisierungs- und Render-Methoden auf. In dem addEntity
In dieser Funktion fügen wir die neue Entität zur Entitätenliste hinzu, hören auf ihre Signale und wenn sie eine Ansicht hat, fügen Sie ihr Sprite der Bühne hinzu.
Wann onEntityDestroyed
ausgelöst wird, entfernen wir die Entität aus der Liste und entfernen ihr Sprite von der Bühne. In dem StopGame
Funktion, die Sie nur aufrufen, wenn Sie das Spiel beenden möchten, entfernen wir die Sprites aller Entitäten von der Bühne und löschen die Entitätenliste, indem Sie die Länge auf Null setzen.
Wow, wir haben es geschafft! Das ist die ganze Spiel-Engine! Von diesem Ausgangspunkt aus könnten wir viele einfache 2D-Arcade-Spiele ohne viel zusätzlichen Code erstellen. Im nächsten Tutorial verwenden wir diese Engine, um ein Asteroids-Weltraum-Shooter zu erstellen.