Fallstudie – Onslaught! Arena

Geoff Blair
Geoff Blair
Matt Hackett
Matt Hackett

Einführung

Im Juni 2010 wurde uns bekannt, dass das lokale „Zine“ Boing Boing einen Wettbewerb für Spieleentwicklung veranstaltete. Das war für uns ein guter Grund, ein schnelles, einfaches Spiel in JavaScript und <canvas> zu entwickeln. Also machten wir uns an die Arbeit. Nach dem Wettbewerb hatten wir noch viele Ideen und wollten das, was wir begonnen hatten, zu Ende bringen. Hier ist die Fallstudie zum Ergebnis, ein kleines Spiel namens Onslaught! Arena.

Der Retro-Look mit Pixelfehlern

Da es bei dem Wettbewerb darum ging, ein Spiel auf der Grundlage eines Chiptunes zu entwickeln, war es wichtig, dass unser Spiel wie ein Retrospiel für das Nintendo Entertainment System aussieht. Die meisten Spiele haben diese Anforderung nicht, aber es ist immer noch ein gängiger künstlerischer Stil (insbesondere bei Indie-Entwicklern), da die Asset-Erstellung einfach ist und er für nostalgische Spieler von Natur aus ansprechend ist.

Ansturm! Pixelgrößen für die Arena
Eine größere Pixelgröße kann die Arbeit im Grafikdesign verringern.

Da diese Sprites so klein sind, haben wir beschlossen, die Pixelzahl zu verdoppeln. Ein Sprite mit 16 × 16 Pixeln hat jetzt also 32 × 32 Pixel usw. Von Anfang an haben wir die Asset-Erstellung doppelt ausgeführt, anstatt den Browser die ganze Arbeit machen zu lassen. Das war einfach einfacher umzusetzen, hatte aber auch einige Vorteile in Bezug auf das Erscheinungsbild.

Hier ein Beispiel für ein Szenario, das wir berücksichtigt haben:

<style>
canvas {
  width: 640px;
  height: 320px;
}
</style>
<canvas width="320" height="240">
  Sorry, your browser is not supported.
</canvas>

Bei dieser Methode werden 1x1-Sprites verwendet, anstatt sie bei der Asset-Erstellung zu verdoppeln. Anschließend übernimmt CSS und ändert die Größe des Canvas selbst. Unsere Benchmarks haben gezeigt, dass diese Methode etwa doppelt so schnell sein kann wie das Rendern größerer (verdoppelter) Bilder. Leider ist bei der CSS-Größenänderung Anti-Aliasing erforderlich, was wir nicht verhindern konnten.

Optionen zum Ändern der Canvas-Größe
Links: Pixelgenaue Assets, die in Photoshop verdoppelt wurden. Rechts: Durch die CSS-Größenänderung wurde ein Unschärfeeffekt hinzugefügt.

Das war für unser Spiel ein Dealbreaker, da einzelne Pixel so wichtig sind. Wenn Sie die Größe Ihres Canvas ändern müssen und Anti-Aliasing für Ihr Projekt geeignet ist, können Sie diesen Ansatz aus Leistungsgründen in Betracht ziehen.

Unterhaltsame Canvas-Tricks

Wir alle wissen, dass <canvas> das neue Ding ist, aber manchmal empfehlen Entwickler immer noch die Verwendung des DOM. Wenn Sie sich nicht sicher sind, welche Sie verwenden sollen, hier ein Beispiel dafür, wie <canvas> uns viel Zeit und Energie gespart hat.

Wenn ein Gegner in Angriff! Arena, blinkt es rot und es wird kurz eine „Schmerz“-Animation angezeigt. Um die Anzahl der Grafiken, die wir erstellen mussten, zu begrenzen, zeigen wir Feinde nur in Richtung nach unten in „Schmerz“ an. Das sieht im Spiel akzeptabel aus und hat beim Erstellen der Sprites viel Zeit gespart. Bei den Bossmonstern war es jedoch irritierend, einen großen Sprite (64 x 64 Pixel oder mehr) zu sehen, der sich im Schmerzframe plötzlich von links oder oben nach unten drehte.

Eine naheliegende Lösung wäre es, für jeden Chef in jeder der acht Richtungen einen Schmerzrahmen zu zeichnen. Das wäre jedoch sehr zeitaufwendig gewesen. Dank <canvas> konnten wir dieses Problem im Code beheben:

Betrachter nimmt Schaden in Ansturm Arena
Mit context.globalCompositeOperation lassen sich interessante Effekte erzielen.

Zuerst zeichnen wir das Monster in einen versteckten „Puffer“ <canvas>, überlagern es mit Rot und rendern das Ergebnis dann wieder auf dem Bildschirm. Der Code sieht in etwa so aus:

// Get the "buffer" canvas (that isn't visible to the user)
var bufferCanvas = document.getElementById("buffer");
var buffer = bufferCanvas.getContext("2d");

// Draw your image on the buffer
buffer.drawImage(image, 0, 0);

// Draw a rectangle over the image using a nice translucent overlay
buffer.save();
buffer.globalCompositeOperation = "source-in";
buffer.fillStyle = "rgba(186, 51, 35, 0.6)"; // red
buffer.fillRect(0, 0, image.width, image.height);
buffer.restore();

// Copy the buffer onto the visible canvas
document.getElementById("stage").getContext("2d").drawImage(bufferCanvas, x, y);

Die Spielschleife

Die Spieleentwicklung unterscheidet sich in einigen wichtigen Punkten von der Webentwicklung. Im Webstack ist es üblich, auf Ereignisse zu reagieren, die über Event-Listener aufgetreten sind. Der Initialisierungscode kann also nichts anderes tun, als auf Eingabeereignisse zu warten. Die Logik eines Spiels ist anders, da sie sich ständig aktualisieren muss. Wenn ein Spieler sich beispielsweise nicht bewegt hat, sollte das die Goblins nicht daran hindern, ihn zu fangen.

Hier ein Beispiel für einen Gameloop:

function main () {
  handleInput();
  update();
  render();
};

setInterval(main, 1);

Der erste wichtige Unterschied besteht darin, dass die handleInput-Funktion nicht sofort etwas tut. Wenn ein Nutzer in einer typischen Webanwendung eine Taste drückt, ist es sinnvoll, die gewünschte Aktion sofort auszuführen. In einem Spiel müssen die Dinge jedoch in chronologischer Reihenfolge ablaufen, damit der Ablauf flüssig ist.

window.addEventListener("mousedown", function(e) {
  // A mouse click means the players wants to attack.
  // We don't actually do that yet, but instead tell the rest
  // of the program about the request.
  buttonStates[e.button] = true;
}, false);

function handleInput() {
  // Here is where we respond to the click
  if (buttonStates[LEFT_BUTTON]) {
    player.attacking = true;
    delete buttonStates[LEFT_BUTTON];
  }
};

Jetzt wissen wir um die Eingabe und können sie in der update-Funktion berücksichtigen, da wir sicher sind, dass sie den übrigen Spielregeln entspricht.

function update() {
  // Check for collisions, states, whatever else is needed

  // If after that the player can still attack, do it!
  if (player.attacking && player.canAttack()) {
    player.attack();
  }
};

Nachdem alles berechnet wurde, ist es an der Zeit, den Bildschirm neu zu zeichnen. Im DOM-Land übernimmt der Browser diese schwere Arbeit. Bei der Verwendung von <canvas> müssen Sie jedoch jedes Mal, wenn etwas passiert, manuell neu zeichnen (in der Regel bei jedem Frame).

function render() {
  // First erase everything, something like:
  context.clearRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);

  // Draw the player (and whatever else you need)
  context.drawImage(
    player.getImage(),
    player.x, player.y
  );
};

Zeitbasierte Modellierung

Bei der zeitbasierten Modellierung werden Sprites basierend auf der verstrichenen Zeit seit der letzten Frame-Aktualisierung bewegt. Mit dieser Technik kann Ihr Spiel so schnell wie möglich laufen und gleichzeitig dafür sorgen, dass sich Sprites mit konstanter Geschwindigkeit bewegen.

Um die zeitbasierte Modellierung zu verwenden, müssen wir die verstrichene Zeit seit dem Zeichnen des letzten Frames erfassen. Wir müssen die update()-Funktion unseres Gameloops erweitern, um dies zu verfolgen.

function update() {

  // NOTE: You'll need to initially seed this.lastUpdate
  // with the current time when your game loop starts
  // this.lastUpdate = Date.now();

  // Calculate elapsed time since last frame
  var now = Date.now();
  var elapsed = (now - this.lastUpdate);
  this.lastUpdate = now;

  // Do stuff with elapsed

};

Da wir jetzt die verstrichene Zeit haben, können wir berechnen, wie weit sich ein bestimmter Sprite pro Frame bewegen soll. Zuerst müssen wir einige Dinge bei einem Sprite-Objekt im Auge behalten: aktuelle Position, Geschwindigkeit und Richtung.

var Sprite = function() {

  // The sprite's position relative to the top left of the game world
  this.position = {x: 0, y: 0};

  // The sprite's direction. A positive x value indicates moving to the right
  this.direction = {x: 1, y: 0};

  // How many pixels the sprite moves per second
  this.speed = 50;
};

Mit diesen Variablen in Gedanken, so würden wir eine Instanz der oben genannten Sprite-Klasse mithilfe der zeitbasierten Modellierung verschieben:

// Determine how far this sprite will move this frame
var distance = (sprite.speed / 1000) * elapsed;

// Apply the movement distance to the sprite's current position
// taking into account its direction
sprite.position.x += (distance * sprite.direction.x);
sprite.position.y += (distance * sprite.direction.y);

Die Werte direction.x und direction.y sollten normalisiert sein, d. h., sie sollten immer zwischen -1 und 1 liegen.

Steuerung

Die Steuerung war wahrscheinlich das größte Hindernis bei der Entwicklung von Onslaught! Arena. Die allererste Demo unterstützte nur die Tastatur: Die Spieler bewegten die Hauptfigur mit den Pfeiltasten über den Bildschirm und feuerten mit der Leertaste in die Richtung, in die sie gerichtet war. Das Spiel ist zwar intuitiv und leicht verständlich, aber auf höheren Schwierigkeitsstufen fast unspielbar. Da jederzeit Dutzende von Feinden und Projektilen auf den Spieler fliegen, ist es wichtig, dass er sich zwischen den Bösewichten bewegen und währenddessen in jede Richtung schießen kann.

Zum Vergleich mit ähnlichen Spielen des Genres haben wir die Maussteuerung für ein Zielkreuz hinzugefügt, mit dem der Charakter seine Angriffe ausrichten kann. Der Charakter konnte weiterhin mit der Tastatur bewegt werden, aber nach dieser Änderung konnte er gleichzeitig in jede Richtung schießen. Hardcore-Spieler schätzten diese Funktion, aber sie hatte die unglückliche Nebenwirkung, dass Nutzer mit Touchpad frustriert waren.

Ansturm! Modales Steuerfeld für die Arena (veraltet)
Ein altes Modalfenster mit den Steuerelementen oder der Anleitung zum Spielen in Onslaught. Arena.

Für Nutzer mit Touchpad haben wir die Pfeiltastensteuerung wieder eingeführt. Diesmal können Sie damit in die Richtungen feuern, in die Sie gedrückt haben. Wir waren der Meinung, dass wir alle Arten von Spielern ansprechen, haben aber unwissentlich zu viel Komplexität in unser Spiel eingeführt. Zu unserer Überraschung haben wir später erfahren, dass einige Spieler die optionalen Maus- (oder Tastatur-) Steuerelemente für das Angreifen nicht kannten, obwohl es Anleitungen gab, die weitgehend ignoriert wurden.

Ansturm! Anleitung zu den Steuerelementen von YouTube Arena
Spieler ignorieren das Tutorial-Overlay meistens. Sie möchten lieber spielen und Spaß haben.

Wir haben auch einige europäische Fans, die uns mitgeteilt haben, dass sie keine typische QWERTY-Tastatur haben und die WASD-Tasten nicht für die Richtungsbewegung verwenden können. Linkshänder haben ähnliche Beschwerden geäußert.

Durch dieses komplexe Steuersystem, das wir implementiert haben, gibt es auch Probleme beim Spielen auf Mobilgeräten. Tatsächlich ist eine der häufigsten Anfragen, Onslaught! Arena ist auf Android-Geräten, iPads und anderen Touch-Geräten ohne Tastatur verfügbar. Eine der Hauptstärken von HTML5 ist seine Portabilität. Es ist also definitiv möglich, das Spiel auf diese Geräte zu portieren. Wir müssen nur die vielen Probleme lösen, vor allem die Steuerung und die Leistung.

Um diese vielen Probleme zu beheben, haben wir mit einer Eingabemethode für das Gameplay begonnen, die nur die Maus (oder Touchbedienung) erfordert. Die Spieler klicken oder tippen auf den Bildschirm und die Hauptfigur geht zu der Stelle, auf die sie gedrückt haben, und greift automatisch den nächstgelegenen Bösewicht an. Der Code sieht in etwa so aus:

// Find the nearest hostile target (if any) to the player
var player = this.getPlayerObject();
var hostile = this.getNearestHostile(player);
if (hostile !== null) {
  // Found one! Shoot in its direction
  var shoot = hostile.boundingBox().center().subtract(
    player.boundingBox().center()
  ).normalize();
}

// Move towards where the player clicked/touched
var move = this.targetReticle.position.clone().subtract(
  player.boundingBox().center()
).normalize();
var distance = this.targetReticle.position.clone().subtract(
  player.boundingBox().center()
).magnitude();

// Prevent jittering if the character is close enough
if (distance < 3) {
  move.zero();
}

// Move the player
if ((move.x !== 0) || (move.y !== 0)) {
  player.setDirection(move);
}

Ohne den zusätzlichen Faktor, auf Feinde zielen zu müssen, kann das Spiel in einigen Situationen einfacher werden. Wir sind jedoch der Meinung, dass es viele Vorteile hat, die Dinge für die Spieler zu vereinfachen. Es entwickeln sich weitere Strategien, z. B. dass der Charakter in der Nähe gefährlicher Feinde positioniert werden muss, um sie anzugreifen. Die Unterstützung von Touch-Geräten ist dabei unerlässlich.

Audio

Neben den Steuerelementen und der Leistung war eines unserer größten Probleme bei der Entwicklung von Onslaught! Arena war das <audio>-Tag von HTML5. Der wohl schlimmste Aspekt ist die Latenz: In fast allen Browsern gibt es eine Verzögerung zwischen dem Aufrufen von .play() und der tatsächlichen Wiedergabe des Tons. Das kann das Spielerlebnis für Gamer ruinieren, insbesondere bei einem schnellen Spiel wie unserem.

Ein weiteres Problem ist, dass das Ereignis „progress“ nicht ausgelöst wird. Dies kann dazu führen, dass der Ladevorgang des Spiels endlos hängt. Aus diesen Gründen haben wir eine sogenannte „Fallback-Methode“ eingeführt, bei der bei einem Fehlschlagen des Flash-Ladens zu HTML5-Audio gewechselt wird. Der Code sieht in etwa so aus:

/*
This example uses the SoundManager 2 library by Scott Schiller:
http://www.schillmania.com/projects/soundmanager2/
*/

// Default to sm2 (Flash)
var api = "sm2";

function initAudio (callback) {
  switch (api) {
    case "sm2":
      soundManager.onerror = (function (init) {
        return function () {
          api = "html5";
          init(callback);
        };
      }(arguments.callee));
      break;
    case "html5":
      var audio = document.createElement("audio");

      if (
        audio
        && audio.canPlayType
        && audio.canPlayType("audio/mpeg;")
      ) {
        callback();
      } else {
        // No audio support :(
      }
      break;
  }
};

Es kann auch wichtig sein, dass ein Spiel Browser unterstützt, die keine MP3-Dateien abspielen (z. B. Mozilla Firefox). In diesem Fall kann die Unterstützung erkannt und zu einer anderen Audioquelle wie Ogg Vorbis gewechselt werden. Dazu wird Code wie der folgende verwendet:

/*
Note: you could instead use "new Audio()" here,
but the client will throw an error if it doesn't support Audio,
which makes using "document.createElement" a safer approach.
*/

var audio = document.createElement("audio");

if (audio && audio.canPlayType) {
  if (!audio.canPlayType("audio/mpeg;")) {
    // Here you know you CANNOT use .mp3 files
    if (audio.canPlayType("audio/ogg; codecs=vorbis")) {
      // Here you know you CAN use .ogg files
    }
  }
}

Daten speichern

Ein Arcade-Shooter ohne Highscore ist undenkbar. Wir wussten, dass einige unserer Spieldaten gespeichert werden müssen. Wir hätten zwar etwas Altbekanntes wie Cookies verwenden können, wollten aber die neuen HTML5-Technologien ausprobieren. Es gibt viele Optionen, darunter lokalen Speicher, Sitzungsspeicher und Web-SQL-Datenbanken.

ALT_TEXT_HERE
Höchstwerte und dein Platz im Spiel werden gespeichert, nachdem du jeden Boss besiegt hast.

Wir haben uns für localStorage entschieden, da es neu, fantastisch und einfach zu bedienen ist. Es unterstützt das Speichern einfacher Schlüssel/Wert-Paare, was für unser einfaches Spiel völlig ausreichend ist. Hier ein einfaches Beispiel für die Verwendung:

if (typeof localStorage == "object") {
  localStorage.setItem("foo", "bar");
  localStorage.getItem("foo"); // Value is "bar"
  localStorage.removeItem("foo");
  localStorage.getItem("foo"); // Value is now null
}

Es gibt jedoch einige Fallstricke, die Sie beachten sollten. Unabhängig davon, was Sie übergeben, werden Werte als Strings gespeichert. Das kann zu unerwarteten Ergebnissen führen:

localStorage.setItem("foo", false);
typeof localStorage.getItem("foo"); // Value is "false" (a string literal)
if (localStorage.getItem("foo")) {
  // It's true!
}

// Don't pass objects into setItem
localStorage.setItem("bar", {"key": "value"});
localStorage.getItem("bar"); // Value is "[object Object]" (a string literal)

// JSON stringify and parse when dealing with localStorage
localStorage.setItem("json", JSON.stringify({"key": "value"}));
typeof localStorage.getItem("json"); // string
JSON.parse(localStorage.getItem("json")); // {"key": "value"}

Zusammenfassung

HTML5 ist einfach toll. Die meisten Implementierungen bieten alles, was Spieleentwickler benötigen, von Grafiken bis zum Speichern des Spielstatus. Es gibt zwar einige Startschwierigkeiten (z. B. Probleme mit dem <audio>-Tag), aber Browserentwickler arbeiten schnell und die Zukunft für HTML5-Spiele sieht vielversprechend aus.

Ansturm! Arena mit einem ausgeblendeten HTML5-Logo
Wenn du in Onslaught „html5“ eingibst, erhältst du einen HTML5-Schild. Arena.