Fallstudie – Onslaught! Arena

Geoff Blair
Geoff Blair
Matt Hackett
Matt Hackett

Einleitung

Im Juni 2010 wurde uns mitgeteilt, dass der lokale „Zine“ Boing Boing ein Spieleentwicklungswettbewerb veranstaltet wurde. Wir sahen dies als eine perfekte Ausrede, um 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 den Anfang zu Ende bringen. Hier ist die Fallstudie des Ergebnisses, ein kleines Spiel namens Onslaught! Arena:

Der verpixelte Retro-Look

Aufgrund der Wettbewerbsgrundlage, die auf einem Chiptune-basierten Spiel basiert, war es wichtig, dass sich unser Spiel wie ein Retrospiel mit Nintendo Entertainment System gestaltet und anfühlt. Die meisten Spiele haben diese Anforderung nicht, aber sie ist immer noch ein gängiger künstlerischer Stil (insbesondere bei Indie-Entwicklern), da sich Assets einfach erstellen lassen und nostalgische Gamer ansprechen.

Ansturm! Arena-Pixelgrößen
Eine Vergrößerung der Pixelgröße kann die Arbeit im Grafikdesign verringern.

Angesichts der kleinen Größe dieser Sprites haben wir beschlossen, unsere Pixel zu verdoppeln. Ein Sprite mit 16 × 16 wäre also jetzt 32 × 32 Pixel und so weiter. Von Anfang an haben wir uns auf die Asset-Erstellung konzentriert, anstatt den Browser den Großteil der Arbeit überlassen zu müssen. Es war einfach zu implementieren, hatte aber auch deutliche optische Vorteile.

Hier ist ein Szenario, das wir in Betracht gezogen haben:

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

Diese Methode besteht aus 1-x-1-Sprites, anstatt sie bei der Asset-Erstellung zu verdoppeln. Von dort übernimmt CSS die Größe des Canvas selbst. Unsere Benchmarks zeigten, dass diese Methode etwa doppelt so schnell sein kann wie das Rendern größerer (verdoppelter) Bilder. Leider beinhaltet die CSS-Größe jedoch Anti-Aliasing, was wir nicht verhindern konnten.

Optionen zum Ändern der Canvasgröße
Links: Die Zahl der pixeligen Assets verdoppelt sich in Photoshop. Rechts: CSS-Größenanpassung hat einen verschwommenen Effekt hinzugefügt.

Dies war ein Deal-Breaker für unser Spiel, da einzelne Pixel so wichtig sind. Wenn Sie jedoch die Größe des Canvas anpassen müssen und Anti-Aliasing für Ihr Projekt geeignet ist, könnten Sie diesen Ansatz aus Leistungsgründen in Betracht ziehen.

Lustige Tricks beim Leinwanddruck

Wir alle wissen, dass <canvas> der neue Trend ist. Manchmal empfehlen Entwickler aber trotzdem, DOM zu verwenden. Hier ist ein Beispiel dafür, wie wir mit <canvas> viel Zeit und Energie gespart haben.

Wenn ein Feind in Onslaught! Arena blinkt, blinkt sie rot und zeigt kurz eine Schmerzanimation an. Um die Anzahl der Grafiken zu begrenzen, die wir erstellen mussten, zeigen wir Feinde nur in Richtung von unten nach unten. Das scheint im Spiel akzeptabel zu sein und viel Zeit für die Sprite-Erstellung zu sparen. Für die Bossmonster war es jedoch verwirrend, ein großes Sprite (mindestens 64 × 64 Pixel) von links oder oben bis plötzlich nach unten zu sehen, um den Schmerzrahmen zu sehen.

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

Behälter bei Ansturm Schaden zufügt! Arena
Mit „context.globalComponentOperation“ können interessante Effekte erzielt werden.

Zuerst zeichnen wir das Monster auf einen versteckten "Puffer" <canvas>, legen es mit Rot über und rendern dann das Ergebnis 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 Punkten von der Webentwicklung. Im Web-Stack wird üblicherweise über Event-Listener auf Ereignisse reagiert, die aufgetreten sind. Daher kann der Initialisierungscode nichts anderes tun, als auf Eingabeereignisse zu warten. Die Logik eines Spiels ist anders, da es ständig aktualisiert werden muss. Wenn sich ein Spieler zum Beispiel nicht bewegt hat, sollte das die Goblins nicht davon abhalten, ihn zu fangen!

Hier ist ein Beispiel für eine Spielschleife:

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

setInterval(main, 1);

Der erste wichtige Unterschied besteht darin, dass die handleInput-Funktion nicht sofort etwas ausführt. Wenn ein Nutzer in einer typischen Webanwendung eine Taste drückt, ist es sinnvoll, sofort die gewünschte Aktion auszuführen. Aber in einem Spiel müssen die Dinge in chronologischer Reihenfolge ablaufen, um richtig zu verlaufen.

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 kennen wir die Eingabe und können sie in der update-Funktion berücksichtigen, da sie den restlichen 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();
  }
};

Sobald alles berechnet wurde, können Sie den Bildschirm neu zeichnen! Im DOM-Land wickelt der Browser diesen Aufwand ab. Wenn Sie <canvas> verwenden, müssen Sie die Zeichnung jedoch jedes Mal manuell neu zeichnen, wenn etwas passiert (in der Regel jeder einzelne 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 sich bewegende Sprites basierend auf der seit der letzten Frame-Aktualisierung verstrichenen Zeit erstellt. Mit dieser Technik läuft Ihr Spiel so schnell wie möglich und sorgt gleichzeitig dafür, dass sich die Sprites mit konstanter Geschwindigkeit bewegen.

Um eine zeitbasierte Modellierung zu verwenden, müssen wir die Zeit erfassen, die seit der Zeichnung des letzten Frames verstrichen ist. Wir müssen die Funktion update() der Spielschleife 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

};

Jetzt, da wir die verstrichene Zeit haben, können wir berechnen, wie weit ein bestimmtes Sprite jeden Frame verschieben soll. Zuerst müssen wir einige Dinge auf 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;
};

Unter Berücksichtigung dieser Variablen wird eine Instanz der obigen Sprite-Klasse folgendermaßen mit zeitbasierter Modellierung verschoben:

// 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 werden, d. h. sie sollten immer zwischen -1 und 1 liegen.

Kontrollen

Steuerelemente waren sicherlich das größte Stolperelement bei der Entwicklung von Onslaught! Arena: In der allerersten Demo wurde nur die Tastatur unterstützt. Die Spieler bewegten die Hauptfigur mit den Pfeiltasten auf dem Bildschirm und feuerten sie mit der Leertaste in die Richtung, in die sie zeigte. Das Spiel war zwar ziemlich intuitiv und leicht verständlich, aber auf schwierigeren Levels kann es dadurch fast nicht spielbar sein. Da Dutzende von Feinden und Projektile zu jeder Zeit auf den Spieler fliegen, ist es wichtig, in der Lage sein, sich zwischen den Bösewichten auszutauschen, während sie in jede beliebige Richtung feuern.

Um einen Vergleich mit ähnlichen Spielen in diesem Genre zu erzielen, haben wir eine Mausunterstützung hinzugefügt, um ein Zielfaser zu steuern, mit dem der Charakter seine Angriffe ausrichten würde. Die Figur konnte zwar mit der Tastatur bewegt werden, aber nach dieser Änderung konnte er gleichzeitig in jeder vollen 360-Grad-Richtung auslösen. Hardcore-Gamer mochten diese Funktion, hatten aber leider den Nachteil, dass Touchpad-Nutzer frustriert waren.

Ansturm! Modales Widget für Arenasteuerelemente (eingestellt)
Ein altes Steuerelement oder ein altes Anleitungsfenster in Onslaught! Arena.

Um Touchpad-Nutzer zu unterstützen, haben wir die Zurück-Pfeiltasten eingeführt, um das Auslösen in die gedrückte Richtung(en) zu ermöglichen. Wir wollten zwar alle Arten von Spielern ansprechen, haben unser Spiel aber auch unwissentlich zu komplex gestaltet. Zu unserer Überraschung erfuhren wir später, dass einige Spieler die optionale Maus (oder Tastatur!) zum Angriff nicht wussten, obwohl die Tutorial-Modale, die weitgehend ignoriert wurden, nicht bekannt waren.

Ansturm! Anleitung für Arenasteuerelemente
Die Spieler ignorieren das Tutorial-Overlay größtenteils. Sie wollen lieber spielen und Spaß haben.

Wir haben auch Glück, dass wir einige europäische Fans haben, aber wir haben die Frustration von ihnen gehört, dass sie möglicherweise keine typischen QWERTY-Tastaturen haben und die WASD-Tasten nicht für Richtungsbewegungen verwenden können. Linkshänder haben ähnliche Beschwerden.

Bei diesem komplexen Steuerschema, das wir implementiert haben, tritt auch das Problem auf, dass auf Mobilgeräten gespielt wird. Eine unserer häufigsten Wünsche ist Onslaught! Arena verfügbar auf Android-Geräten, iPads und anderen Touch-Geräten ohne Tastatur. Eine der größten Stärken von HTML5 ist die Portabilität. Es ist also auf jeden Fall machbar, das Spiel auf diese Geräte zu übertragen. Wir müssen nur die vielen Probleme lösen, insbesondere in Bezug auf Steuerung und Leistung.

Um diese vielen Probleme anzugehen, haben wir angefangen, mit einer Ein-Eingabemethode zu spielen, die nur die Maus (oder Berührung) beinhaltet. Die Spieler klicken oder berühren den Bildschirm und die Hauptfigur geht auf die gedrückte Stelle zu und greift automatisch den nächsten 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);
}

Es ist zwar in einigen Situationen einfacher, den zusätzlichen Faktor zu entfallen, bei dem das Targeting auf Feinde erfolgen muss, aber es hat unserer Meinung nach viele Vorteile, die Sache für den Spieler zu vereinfachen. Es kommen auch andere Strategien ins Spiel, zum Beispiel, dass man den Charakter in der Nähe gefährlicher Feinde positionieren muss, um ihn anzugreifen, und die Unterstützung von Touch-Geräten ist von unschätzbarem Wert.

Audio

Kontrolle und Leistung sind eines unserer größten Probleme bei der Entwicklung von Onslaught! Arena war das <audio>-Tag von HTML5. Der wahrscheinlich schlimmste Aspekt ist die Latenz: In fast allen Browsern gibt es eine Verzögerung zwischen dem Aufruf von .play() und dem tatsächlichen Abspielen des Tons. Dies kann den Gamern schaden, insbesondere bei schnelllebigen Spielen wie unserem.

Andere Probleme können zum Beispiel sein, wenn das Ereignis „progress“ nicht ausgelöst wird und der Ladefluss des Spiels auf unbestimmte Zeit hängt. Aus diesen Gründen haben wir eine sogenannte Fall-Forward-Methode gewählt. Wenn Flash nicht geladen werden kann, wechseln wir zu HTML5-Audio. 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;
  }
};

Außerdem kann es wichtig sein, dass ein Spiel Browser unterstützt, die keine MP3-Dateien abspielen (z. B. Mozilla Firefox). Wenn dies der Fall ist, kann die Unterstützung erkannt und auf so etwas wie Ogg Vorbis mit Code wie diesem umgeschaltet werden:

/*
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 werden gespeichert

Ohne Highscores ist ein Shoot 'em up im Arcade-Stil nicht möglich! Wir wussten, dass einige unserer Spieldaten nicht verloren gingen, und auch wenn wir so etwas wie Cookies hätten verwenden können, wollten wir uns mit den lustigen neuen HTML5-Technologien beschäftigen. Es gibt mit Sicherheit keinen Mangel an Optionen, darunter „Lokaler Speicher“, „Sitzungsspeicher“ und „Web SQL-Datenbanken“.

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

Wir haben uns für localStorage entschieden, weil es neu, fantastisch und einfach zu verwenden ist. Es unterstützt das Speichern grundlegender Schlüssel/Wert-Paare – für unser einfaches Spiel. 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 ein paar Tricks, die Sie kennen sollten. Unabhängig davon, was Sie übergeben, werden Werte als Strings gespeichert, was zu unerwarteten Ergebnissen führen kann:

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 lässt sich hervorragend nutzen. Die meisten Implementierungen verarbeiten alles, was ein Spieleentwickler braucht, von der Grafik bis zum Speichern des Spielstatus. Es gibt zwar immer wieder Probleme, zum Beispiel mit <audio>-Tags, aber die Browserentwickler kommen schnell voran. Da die Dinge bereits so gut sind, sieht die Zukunft für Spiele, die auf HTML5 basieren, rosig aus.

Ansturm! Arena mit verstecktem HTML5-Logo
Du erhältst ein HTML5-Schild, wenn du beim Spielen von „Onslaught“ „html5“ eingibst. Arena.