Étude de cas : Onslaught! Salle

Introduction

En juin 2010, nous avons appris que le magazine local Boing Boing organisait un concours de développement de jeux. Nous avons vu cela comme une excellente excuse pour créer un jeu simple et rapide en JavaScript et <canvas>. Nous nous sommes donc mis au travail. Après le concours, nous avions encore beaucoup d'idées et nous voulions terminer ce que nous avions commencé. Voici l'étude de cas du résultat, un petit jeu appelé Onslaught! Arena.

L'aspect rétro et pixelisé

Il était important que notre jeu ressemble à un jeu rétro sur Nintendo Entertainment System, étant donné la prémisse du concours consistant à développer un jeu basé sur un chiptune. La plupart des jeux ne nécessitent pas cette exigence, mais il s'agit toujours d'un style artistique courant (en particulier parmi les développeurs indépendants) en raison de la facilité de création d'assets et de l'attrait naturel qu'il exerce sur les joueurs nostalgiques.

Assaut ! Tailles de pixels de l&#39;arène
Augmenter la taille des pixels peut réduire le travail de conception graphique.

Étant donné la petite taille de ces sprites, nous avons décidé de doubler le nombre de pixels, ce qui signifie qu'un sprite de 16 x 16 pixels sera désormais de 32 x 32 pixels, etc. Dès le départ, nous doublions la création d'éléments au lieu de laisser le navigateur faire le gros du travail. Cette approche était simplement plus facile à implémenter, mais elle présentait également des avantages esthétiques certains.

Voici un scénario que nous avons envisagé:

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

Cette méthode consisterait à utiliser des sprites 1x1 au lieu de les doubler du côté de la création d'éléments. Le CSS prend alors le relais et redimensionne le canevas lui-même. Nos benchmarks ont révélé que cette méthode peut être environ deux fois plus rapide que le rendu d'images plus grandes (doublées), mais malheureusement, le redimensionnement CSS inclut l'anticrénelage, ce que nous n'avons pas pu empêcher.

Options de redimensionnement du canevas
À gauche: éléments Pixels parfaits doublés dans Photoshop. À droite: le redimensionnement CSS a ajouté un effet flou.

Cela a été un facteur décisif pour notre jeu, car les pixels individuels sont très importants. Toutefois, si vous devez redimensionner votre canevas et que l'anticrénelage est approprié pour votre projet, vous pouvez envisager cette approche pour des raisons de performances.

Astuces amusantes pour le canevas

Nous savons tous que <canvas> est la nouvelle tendance, mais les développeurs recommandent parfois encore d'utiliser le DOM. Si vous ne savez pas laquelle utiliser, voici un exemple de la façon dont <canvas> nous a fait gagner beaucoup de temps et d'énergie.

Lorsqu'un ennemi est touché dans Onslaught! Arena, il clignote en rouge et affiche brièvement une animation de "douleur". Pour limiter le nombre de graphiques que nous devions créer, nous n'affichons les ennemis en "douleur" que vers le bas. Cela semble acceptable dans le jeu et m'a fait gagner beaucoup de temps dans la création de sprites. Cependant, pour les monstres de boss, il était choquant de voir un grand sprite (64 x 64 pixels ou plus) passer de la position face à gauche ou vers le haut à la position face vers le bas pour le frame de douleur.

Une solution évidente consisterait à dessiner un cadre de douleur pour chaque boss dans chacune des huit directions, mais cela aurait été très long. Grâce à <canvas>, nous avons pu résoudre ce problème dans le code:

Beholder taking damage in Onslaught! Salle
Vous pouvez créer des effets intéressants à l'aide de context.globalCompositeOperation.

Nous commençons par dessiner le monstre dans une mémoire tampon <canvas> masquée, puis nous le superposons en rouge, puis nous affichons le résultat à l'écran. Le code se présente comme suit:

// 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);

Boucle de jeu

Le développement de jeux présente certaines différences notables par rapport au développement Web. Dans la pile Web, il est courant de réagir aux événements qui se sont produits via des écouteurs d'événements. Le code d'initialisation ne peut donc pas faire autre chose que d'écouter les événements d'entrée. La logique d'un jeu est différente, car elle doit se mettre à jour en permanence. Par exemple, si un joueur n'a pas bougé, cela ne doit pas empêcher les gobelins de l'attraper.

Voici un exemple de boucle de jeu:

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

setInterval(main, 1);

La première différence importante est que la fonction handleInput ne fait rien immédiatement. Si un utilisateur appuie sur une touche dans une application Web classique, il est logique d'effectuer immédiatement l'action souhaitée. Toutefois, dans un jeu, les événements doivent se dérouler dans l'ordre chronologique pour que le flux soit correct.

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];
  }
};

Nous connaissons maintenant l'entrée et pouvons la prendre en compte dans la fonction update, en sachant qu'elle respectera le reste des règles du jeu.

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();
  }
};

Enfin, une fois tout calculé, il est temps de redessiner l'écran. Dans le DOM, le navigateur gère cette tâche. Toutefois, lorsque vous utilisez <canvas>, vous devez redessiner manuellement chaque fois qu'un événement se produit (ce qui est généralement le cas à chaque 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
  );
};

Modélisation basée sur le temps

La modélisation basée sur le temps est le concept de déplacement des sprites en fonction du temps écoulé depuis la dernière mise à jour de frame. Cette technique permet à votre jeu de s'exécuter aussi rapidement que possible tout en veillant à ce que les sprites se déplacent à des vitesses cohérentes.

Pour utiliser la modélisation basée sur le temps, nous devons capturer le temps écoulé depuis le dernier frame dessiné. Nous devrons ajouter la fonction update() de notre boucle de jeu pour suivre cela.

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

};

Maintenant que nous avons le temps écoulé, nous pouvons calculer la distance à laquelle un sprite donné doit se déplacer à chaque image. Tout d'abord, nous devons suivre quelques éléments d'un objet de sprite: la position actuelle, la vitesse et la direction.

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;
};

Compte tenu de ces variables, voici comment déplacer une instance de la classe de sprite ci-dessus à l'aide de la modélisation basée sur le temps:

// 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);

Notez que les valeurs direction.x et direction.y doivent être normalisées, ce qui signifie qu'elles doivent toujours se situer entre -1 et 1.

Commandes

Les commandes ont peut-être été le plus grand obstacle lors du développement de Onslaught ! Arena. La toute première démo n'était compatible qu'avec le clavier. Les joueurs déplaçaient le personnage principal sur l'écran à l'aide des touches fléchées et tiraient dans la direction qu'il faisait face avec la barre d'espace. Bien que ce système soit quelque peu intuitif et facile à comprendre, il rendait le jeu presque impossible à jouer aux niveaux les plus difficiles. Avec des dizaines d'ennemis et de projectiles qui volent vers le joueur à tout moment, il est impératif de pouvoir se faufiler entre les méchants tout en tirant dans toutes les directions.

Pour comparer le jeu à d'autres jeux de son genre, nous avons ajouté la compatibilité avec la souris pour contrôler un réticule de ciblage, que le personnage utilisera pour viser ses attaques. Le personnage pouvait toujours être déplacé à l'aide du clavier, mais après ce changement, il pouvait tirer simultanément dans toutes les directions à 360 degrés. Les joueurs hardcore ont apprécié cette fonctionnalité, mais elle avait le désavantage de frustrer les utilisateurs de pavé tactile.

Assaut ! Fenêtre modale de commandes de l&#39;Arène (obsolète)
Ancienne fenêtre modale de commandes ou "Comment jouer" dans Onslaught ! Arena.

Pour les utilisateurs de pavé tactile, nous avons rétabli les commandes des touches fléchées, cette fois pour permettre de tirer dans la ou les directions enfoncées. Bien que nous pensions répondre aux besoins de tous les types de joueurs, nous introduisions également, à notre insu, trop de complexité dans notre jeu. À notre grande surprise, nous avons appris plus tard que certains joueurs ne connaissaient pas les commandes facultatives de la souris (ou du clavier) pour attaquer, malgré les tutoriels, qui étaient largement ignorés.

Assaut ! Tutoriel sur les commandes Arena
Les joueurs ignorent généralement le tutoriel superposé. Ils préfèrent jouer et s'amuser.

Nous avons également la chance d'avoir des fans européens, mais nous avons entendu leur frustration de ne pas avoir de claviers QWERTY typiques et de ne pas pouvoir utiliser les touches WASD pour les mouvements directionnels. Les joueurs gauchers ont exprimé des plaintes similaires.

Avec ce schéma de contrôle complexe que nous avons implémenté, il y a aussi le problème de la lecture sur les appareils mobiles. En effet, l'une de nos demandes les plus courantes est de créer Onslaught! Arena disponible sur Android, iPad et d'autres appareils tactiles (sans clavier). L'une des principales forces de HTML5 est sa portabilité. Il est donc tout à fait possible de lancer le jeu sur ces appareils. Il nous suffit de résoudre les nombreux problèmes (en particulier les commandes et les performances).

Pour résoudre ces nombreux problèmes, nous avons commencé à jouer avec une méthode de jeu à entrée unique qui n'implique que l'interaction avec la souris (ou l'écran tactile). Les joueurs cliquent ou appuient sur l'écran, et le personnage principal se dirige vers l'emplacement sélectionné, attaquant automatiquement le méchant le plus proche. Le code se présente comme suit:

// 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);
}

Supprimer le facteur supplémentaire consistant à viser les ennemis peut rendre le jeu plus facile dans certaines situations, mais nous pensons que simplifier les choses pour le joueur présente de nombreux avantages. D'autres stratégies émergent, comme le fait de placer le personnage à proximité d'ennemis dangereux pour les cibler, et la possibilité de prendre en charge les appareils tactiles est inestimable.

Audio

Parmi les commandes et les performances, l'un de nos plus gros problèmes lors du développement de Onslaught! Arena était la balise <audio> de HTML5. Le pire aspect est probablement la latence: dans presque tous les navigateurs, il existe un délai entre l'appel de .play() et la lecture du son. Cela peut gâcher l'expérience d'un joueur, en particulier lorsqu'il joue à un jeu rapide comme le nôtre.

D'autres problèmes peuvent se produire, par exemple l'événement "progress" ne se déclenche pas, ce qui peut entraîner le blocage du flux de chargement du jeu indéfiniment. Pour ces raisons, nous avons adopté ce que nous appelons une méthode de "redirection". Si le chargement de Flash échoue, nous passons à l'audio HTML5. Le code se présente comme suit:

/*
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;
  }
};

Il peut également être important qu'un jeu soit compatible avec les navigateurs qui ne lisent pas les fichiers MP3 (comme Mozilla Firefox). Dans ce cas, la prise en charge peut être détectée et remplacée par un format tel que Ogg Vorbis, avec un code semblable à celui-ci:

/*
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
    }
  }
}

Enregistrer des données

Un jeu de tir d'arcade ne peut pas se passer de scores élevés. Nous savions que certaines de nos données de jeu devaient persister. Bien que nous aurions pu utiliser des éléments anciens comme des cookies, nous voulions explorer les nouvelles technologies HTML5 amusantes. Les options ne manquent pas, y compris le stockage local, le stockage de session et les bases de données SQL Web.

ALT_TEXT_HERE
Les meilleurs scores sont enregistrés, ainsi que votre place dans le jeu après avoir vaincu chaque boss.

Nous avons décidé d'utiliser localStorage, car il s'agit d'une nouveauté, d'une fonctionnalité géniale et facile à utiliser. Il permet d'enregistrer des paires clé-valeur de base, ce qui est tout ce dont notre jeu simple a besoin. Voici un exemple simple d'utilisation:

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

Il y a quelques points à prendre en compte. Quelle que soit la valeur que vous transmettez, les valeurs sont stockées sous forme de chaînes, ce qui peut entraîner des résultats inattendus:

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"}

Résumé

HTML5 est un langage de programmation très pratique. La plupart des implémentations gèrent tout ce dont un développeur de jeux a besoin, des graphismes à l'enregistrement de l'état du jeu. Bien que des difficultés de croissance se soient produites (comme les problèmes liés à la balise <audio>), les développeurs de navigateurs avancent rapidement. Avec des choses déjà aussi géniales, l'avenir s'annonce prometteur pour les jeux créés en HTML5.

Assaut ! Arène avec un logo HTML5 masqué
Vous pouvez obtenir un bouclier HTML5 en saisissant "html5" lorsque vous jouez à Onslaught. Arena.