Entwicklung von Spielen

2D-Spiele-Engines für das Web

Michael Oneppo

Codebeispiel herunterladen

Angenommen, da ist ein Entwickler, der ein Spiel schreiben möchte. Dieser Entwickler sieht sich um, welche Tools verfügbar sind, und ist von den Optionen enttäuscht. Daher entscheidet sich dieser Entwickler, eine benutzerdefinierte Engine zu erstellen, die alle Anforderungen an das aktuelle Spielkonzept erfüllt. Sie soll auch alle künftigen Anforderungen an die Spielentwicklung erfüllen, die irgendwer irgendwann haben könnte. Und so kommt es, dass der Entwickler nie ein Spiel schreibt.

Dies ist ein tragischer Fall, der häufig vorkommt. Glücklicherweise hat diese Geschichte Dutzende von Engines für die Spielentwicklung für alle möglichen Plattformen hervorgebracht. Damit einher gehen eine Vielzahl von Methoden, Tools und Lizenzierungsmodellen. Auf JavaScripting.com sind derzeit 14 dedizierte Open Source-Spielbibliotheken gelistet. Noch nicht hinzugezählt sind Engines für physikalische Eigenschaften, Audiobibliotheken und spezialisierte Eingabesysteme für Spiele. Bei dieser Auswahl sollte es eigentlich eine Spiele-Engine geben, die sich für das Spielprojekt eignet, das Ihnen vorschwebt.

In diesem Artikel werden drei gängigen Open Source-2D-Spiele-Engines für das Web vorgestellt: Crafty, Pixi und Phaser. Zum Vergleichen und Gegenüberstellen dieser Bibliotheken habe ich das Spiel "Ping" aus meinem ersten Artikel (msdn.microsoft.com/magazine/dn913185) für jede Engine portiert, um festzustellen, wie das Ergebnis ist. Da ich mich auf 2D beschränke, lasse ich eine Reihe von 3D-Spiele-Engines für das Web unberücksichtigt. Auf diese werde ich in Zukunft eingehen. Im Moment konzentriere ich mich auf 2D und die dafür gebotenen Möglichkeiten.

Crafty

Crafty (craftyjs.com) ist in erster Linie für kachelbasierte Spiele gedacht, wenngleich diese Engine sich auch für eine Vielzahl von 2D-Spielen, so auch für mein Spiel Ping, gut eignet. Grundlage von Crafty ist ein Objektmodell/Abhängigkeitssystem, das mit sog. Komponenten arbeitet.

Komponenten sind vergleichbar mit Klassen, die Sie mit bestimmten Attributen und Aktionen definieren. Jede Instanz einer Komponente wird als Entität bezeichnet. Komponenten können umfassend kombiniert werden, um komplexe, funktionsreiche Entitäten herzustellen. Wenn Sie z. B. in Crafty ein Spritesheet erstellen möchten, verwenden Sie die "Sprite"-Funktion zum Generieren von Komponenten, die Ihre Sprites darstellen. Hier sehen Sie, wie ich die Sprite-Typen anhand eines Bilds definiere:

Crafty.sprite("sprites.png", {
  PlayerSprite: [0, 128, 128, 128],
  OpponentSprite: [0, 0, 128, 128],
  BallSprite: [128, 128, 64, 64],
  UpSprite: [128,0,64,64],
  DownSprite: [128,64,64,64],
  LeftSprite: [192,0,64,64],
  RightSprite: [192,64,64,64]});

Wenn Sie eine Entität vom Typ "Ball" erstellen möchten, die das Bild des Balls auf dem Spritesheet verwendet, fügen Sie beim Erstellen der Entität die "BallSprite"-Komponente unter Angabe der Funktion "e" wie folgt hinzu:

Crafty.e("Ball, BallSprite");

Dadurch wird sie aber noch nicht auf dem Bildschirm gezeichnet. Sie müssen Crafty anweisen, dass Sie diese Entität in 2D aktivieren möchten (sodass sie eine X- und eine Y-Position erhält) und dass Sie sie mithilfe von DOM-Elementen zeichnen wollen:

Crafty.e("Ball, 2D, DOM, BallSprite");

Wenn Sie Ihre Elemente auf einer Canvas zeichnen möchten, ist dies so einfach wie das Ersetzen der DOM-Komponente (Document Objekt Model) durch Canvas.

Entitäten verhalten sich wie unabhängige Objekte auf dem Bildschirm, indem sie auf Ereignisse reagieren. Wie viele JavaScript-Bibliotheken haben Crafty-Entitäten eine Bindungsfunktion zum Reagieren auf Ereignisse. Wichtig ist der Hinweis, dass es sich um Crafty-spezifische Ereignisse handelt. Wenn Sie möchten, dass eine Entität in jedem Frame etwas tut, lassen Sie sie auf das "EnterFrame"-Ereignis reagieren. Dies ist genau das, was erforderlich ist, um den Ball basierend auf demjenigen, der ihn hält, zu bewegen und bei Bedarf für das entsprechende physikalische Verhalten zu sorgen. Abbildung 1 zeigt die Initialisierung der "Ball"-Entität mithilfe einer "EnterFrame"-Ereignisfunktion, die die Bewegung des Balls abdeckt.

Abbildung 1: Definition der "Ball"-Entität

Crafty.e("Ball, 2D, DOM, BallSprite, Collision")
  .attr({ x: width/2, y: height/2, velocity: [0,0], owner: 'User' })
  .bind('EnterFrame', function () {
    // If there's an owner, have the ball follow the owner.
    // Nudge the ball to the left or right of the player
    // so it doesn't overlap.
    if (this.owner) {
      var owner = Crafty(this.owner);
      switch(this.owner) {
        case 'User':
          this.x = owner.x + 128;
          break;
        case 'Opponent':
          this.x = owner.x - 64;
            }
            this.y = owner.y + 32;
            return;
    }
    // Bounce the ball off the ceiling or floor.
    if (this.y <= 0 || this.y >= (height - 64))
      this.velocity[1] = -this.velocity[1];
    // Move the ball based on its velocity, which is defined in
    // pixels per millisecond.
    var millis = 1000 / Crafty.timer.FPS();
    this.x += this.velocity[0] * millis;
    this.y += this.velocity[1] * millis;
  })

Wenn Sie genau hingucken, sehen Sie, dass "this" häufig verwendet wird, um auf die Eigenschaften der Entität zu verweisen. Die integrierten Crafty-Elemente "this.x" und "this.y" definieren die Position des Balls. Bei der Erstellung des Balls mit der 2D-Komponente wird diese Funktionalität hinzugefügt. Die Anweisung "this.velocity" ist spezifisch für meinem Code. Sie können sehen, dass sie als eine Eigenschaft mit der "attr"-Funktion definiert ist. Dies gilt auch für "this.owner", womit ich ermittle, ob einer der Spieler den Ball hält.

Crafty bietet mehrere integrierte Komponenten, von denen ich aber in meinem Beispielspiel Ping nicht sehr viele verwende. Hier sind nur einige:

  • Gravity: Crafty hat eine Schwerkraftsengine, die eine Entität automatisch nach unten zieht. Sie können eine beliebige Anzahl von Elementtypen definieren, um ihre Bewegung zu beenden.
  • TwoWay/FourWay/MultiWay Motion: Diese Komponenten bieten Ihnen eine Vielzahl von Bewegungseingabemethoden im Stil von Arcade-Spielen. "TwoWay" ist für Jump ’n’ Run-Spiele mit Optionen für Nach-links-, Nach-rechts- und Sprungbewegungen, die an die gewünschten Tasten gebunden werden. "FourWay" ermöglicht eine Bewegung von oben nach unten in die Hauptrichtungen. "MultiWay" lässt benutzerdefinierte Eingaben mit jeweils einer beliebigen Richtung zu.
  • Particles: Crafty bietet ein schnelles Partikelsystem, das nur für die Canvas-Zeichnung verwendet werden kann. Als weiche, runde, einfarbige Bilder sind die Partikel besonders für Rauch, Feuer und Explosionen geeignet.

Der in Crafty implementierte Code für Ping ist im Codedownload zu diesem Artikel verfügbar. Sehen Sie sich an, wie ich die Dinge portiert habe, insbesondere die künstliche Intelligenz für den Gegner.

Pixi.js

Pixi.js (pixijs.com) ist ein einfacher, hardwarenaher 2D-Webrenderer. Pixi.js kann auf die 2D-Canvas verzichten und direkt in WebGL eintauchen, wodurch die Leistung erheblich verbessert wird. Ein Pixi.js-Spiel hat zwei Hauptkomponenten: eine Stage zum Beschreiben des Layout Ihres Spiels in einem Szenengraph, und einen Renderer, der die Elemente auf der Stage mithilfe von Canvas oder WebGL tatsächlich auf den Bildschirm zeichnet. Standardmäßig nutzt Pixi.js WebGL, sofern verfügbar:

$(document).ready(function() {
  // The stage to play your game.
  stage = new PIXI.Stage(0xFFFFFFFF);
  lastUpdate = 0;
  // autoDetectRenderer() finds the fastest renderer you've got.
  renderer = PIXI.autoDetectRenderer(innerWidth, innerHeight);
  document.body.appendChild(renderer.view);
});

Pixi.js verfügt über einen Szenengraph und Transformationen, aber wichtig der Hinweis, dass Sie die Szene selbst rendern müssen. In meinem Spiel "Ping" setze ich die Renderingschleife mit "RequestAnimationFrame" wie folgt fort:

function update(time) {
  var t = time - lastUpdate;
  lastUpdate = time;
  ball.update(t);
  ai.update();
  // New: You actually have to render the scene
  renderer.render(stage);
  requestAnimationFrame(update);
}

Anders als Crafty schreibt Pixi.js Ihnen nicht viel zur Struktur Ihres Spiels vor. Deshalb kann ich nahezu denselben Code wie im ursprünglichen Ping-Spiel mit geringfügigen Änderungen verwenden.

Der große Unterschied, den Sie erkennen, ist, dass Pixi.js Ihnen das Laden von Sprites als Bilder aus Dateien entweder einzeln nacheinander oder als gekacheltes Bild mithilfe eines speziellen Tools namens TexturePacker erlaubt. Dieses Tool führt automatisch eine Gruppe einzelner Bilder in einer einzelnen, optimierte Datei mit einer zugehörigen JSON-Datei zusammen, die die Positionen der Bilder beschreibt. Ich habe TexturePacker zum Erstellen des Spritesheets verwendet. Abbildung 2 zeigt die aktualisierte "ready"-Funktion, die die Spritebilder lädt.

Abbildung 2: Laden eines Spritesheets in Pixi.js

$(document).ready(function() {
  // Create an new instance of a Pixi stage.
  stage = new PIXI.Stage(0xFFFFFFFF);
  lastUpdate = 0;
  // Create a renderer instance.
  renderer = PIXI.autoDetectRenderer(innerWidth, innerHeight);
  document.body.appendChild(renderer.view);
  var images = ["sprites.json"];
  var loader = new PIXI.AssetLoader(images);
  loader.onComplete = startGame;
  loader.load();
});

Das Ladeprogramm ist asynchron, da es eine Funktion erfordert, die aufgerufen wird, wenn das Laden beendet ist. Die verbleibenden Spielinitialisierungen wie das Erstellen von Spieler, Gegner und Ball befinden sich in der neuen "startGame"-Funktion. Um diese neu initialisierten Sprites einsetzen zu können, verwenden Sie die "PIXI.Sprite.from-Frame"-Methode, die im Array von Sprites in der JSON-Datei einen Index verwendet.

Ein letzter Unterschied, den Pixi.js aufweist, ist ein Touch- und Mauseingabesystem. Um Sprites eingabebereit zu machen, lege ich die "interactive"-Eigenschaft auf "true" fest und füge dem Sprite einige Ereignishandler direkt hinzu. An die Nach-oben-/Nach-unten-Tasten habe ich beispielsweise Ereignisse gebunden, um den Spieler nach oben und unten zu bewegen (siehe Abbildung 3).

Abbildung 3: Binden von Ereignissen, um den Spieler nach oben und unten zu bewegen

up = new PIXI.Sprite.fromFrame(3);
up.interactive = true;
stage.addChild(up);
up.touchstart = up.mousedown = function() {
  player.move(-distance);
}
down = new PIXI.Sprite.fromFrame(4);
down.interactive = true;
stage.addChild(down);
down.touchstart = down.mousedown = function() {
  player.move(distance);
}

Es ist wichtig zu beachten, dass ich jedes Sprite der Szene manuell hinzufügen musste, um es sichtbar zu machen. Sie können meine Version von in Pixi.js entwickeltem Ping im Codedownload zu diesem Artikel überprüfen.

Phaser

Phaser (phaser.io) ist eine voll ausgestattete Spiele-Engine, die intern Pixi verwendet, um alle Renderingaufgaben auszuführen. Phaser hat eine lange Liste von Features, einschließlich framebasierter Sprite-Animationen, Musik und Audio, ein Spielstatussystem und physikalische Eigenschaften mit drei verschiedenen zugehörigen Bibliotheken.

Da ich Ping bereits in Pixi.js portiert habe, war das Portieren in Phaser einfach. Phaser optimiert die Objekterstellung basierend auf Pixi.js:

ball = game.add.sprite(x, y, 'sprites');

Wenngleich Phaser präziser sein kann, gibt es Stellen, an denen die Dinge komplexer sind. Im letzten Parameter im vorherigen Code verweist "sprite" auf ein Bild, das beim Start geladen wird:

game.load.image('sprites', 'sprites.png');

Phaser hat ein anderes Spritesheetsystem für Bilder als Pixi.js. Sie können ein Bild in Kacheln unterteilen. Um es zu nutzen, rufen Sie einen Befehl wie den folgenden auf:

game.load.spritesheet('sprites', 'mySprites.png',
  spriteWidth, spriteHeight, totalSprites);

In meinem Beispiel verwende ich diese jedoch Funktion nicht. Der Code, den ich zuvor gezeigt habe, setzt voraus, dass alle Sprites die gleiche Größe haben, aber das Ping-Spritesheet weist Sprites mit 64 Pixel und 128 Pixel auf. Daher musste ich jedes Spritebild manuell zuschneiden. Zum Zuschneiden des Bildes für den Ball habe ich ein Zuschnittrechteck für das Sprite festgelegt:

ball.cropRect = new Phaser.Rectangle(0, 64, 64, 64);
ball.updateCrop();

Hoffentlich wird dadurch die Flexibilität von Phaser veranschaulicht. Diese Flexibilität kommt auch auf andere Weise zum Ausdruck. Sie können Ihr Spiel ereignisbasiert gestalten oder auf Ereignisse prüfen lassen, auf die in einer "update"-Funktion sequenziell reagiert wird. Um z. B. eine Tasteneingabe zu verarbeiten, können Sie auf ein Ereignis reagieren:

game.input.keyboard.onDownCallback = function(key) {
  console.log(key + " was pressed.");
}

oder prüfen Sie, ob in der "update"-Schleife eine Taste gedrückt wird:

function update() {
  if (game.input.keyboard.isDown('a'.charCodeAt(0)) {
    console.log("'A' key was pressed");
  }
}

Dies gilt für eine Vielzahl von Ereignissen in Phaser, und Sie können je nach Vorliebe oder Anforderung mit einer sequenziellen oder asynchronen Codierung arbeiten. Gelegentlich bietet Phaser nur die letztgenannte Form der Ereignisbehandlung. Ein gutes Beispiel ist das Arcade-System für physikalisches Verhalten, bei dem Sie einen Funktionsaufruf explizit für jede Aktualisierung zwecks Überprüfung auf Kollisionen zwischen Objekten angeben müssen:

game.physics.arcade.collide(player, ball, function() {
  ballOwner = player;
});

Dieser Code bestimmt, ob der Ball in Kontakt mit dem Spielersprite gekommen ist und übergibt in diesem Fall die Kontrolle des Balls an den Spieler. Sehen Sie sich meine Implementierung von Ping in Phaser im Codedownload zu diesem Artikel an.

Zusammenfassung

Wenn Sie sich für eine dedizierte 2D-JavaScript-Spiele-Engine interessieren, gibt es viele Optionen zum Erfüllen Ihrer Anforderungen. Bei der Wahl eines Frameworks sind zwei wichtige Faktoren zu berücksichtigen:

Nutzen Sie nur die Features, die Sie wirklich brauchen: Lassen Sie sich nicht von zahlreichen Features, allumfassenden monumentalen Frameworks und anderen Feinheiten verwirren. Wenn diese Feinheiten nicht gerade für bestimmte Aspekte Ihres Spiels von Interesse sind, stehen Sie Ihnen wahrscheinlich nur im Weg. Prüfen Sie auch, wie die Features in Komponenten gegliedert sind. Funktioniert das gesamte System noch, wenn Sie das System für physikalisches Verhalten entfernen? Hängt das Framework vom System für physikalisches Verhalten ab, um andere Funktionalität bereitzustellen?

Verstehen der Funktionsweise der einzelnen Engines: Versuchen Sie zu verstehen, wie Sie das Framework anpassen oder erweitern können. Wenn Sie etwas Ungewöhnliches vorhaben, ist es wahrscheinlich, dass Sie benutzerdefinierten Code schreiben müssen, der umfassend auf dem ausgewählten Framework basiert. An diesem Punkt können entweder ein neues, unabhängiges Feature schreiben oder die Funktionalität des Frameworks erweitern. Wenn Sie das Framework erweitern, anstatt es zu ergänzen, haben Sie letztendlich besser verwaltbaren Code.

Mit den hier behandelten Frameworks bekommen Sie viele dieser Aspekte in den Griff, weshalb Sie unbedingt einen Blick darauf werfen sollten. Es gibt Dutzende von Engines, Bibliotheken und Frameworks. Recherchieren Sie also erst einmal, bevor Sie das nächste Spielentwicklungsabenteuer in Angriff nehmen.


Michael Oneppo ist Kreativtechnologe und früherer Programmmanager im Direct3D-Team bei Microsoft. In den letzten Jahren engagierte er sich als technischer Direktor in der gemeinnützigen Einrichtung "Library For All" und arbeitete an einem Master-Abschluss im "Interactive Telecommunications Program" der Universität von New York (NYU).

Unser Dank gilt dem folgenden technischen Experten von Microsoft für die Durchsicht dieses Artikels: Justin Garrett