Étude de cas : Onslaught! Salle

Présentation

En juin 2010, nous avons été informés que le magazine "zine" Boing Boing organise un concours de développement de jeux. Nous avons vu qu'il s'agissait d'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! dans l'Arena.

Un look rétro pixélisé

Il était important que notre jeu ressemble à un jeu Nintendo Entertainment System rétro, étant donné le présence d'un concours pour développer un jeu basé sur un chiptune. Cette exigence n'est pas obligatoire pour la plupart des jeux, mais elle reste un style artistique courant (en particulier parmi les développeurs indépendants), en raison de sa facilité de création d'éléments et de son attrait naturel pour les joueurs nostalgiques.

Tué ! Tailles de pixels Arena
L'augmentation de la taille des pixels peut réduire le travail de conception graphique.

Compte tenu de la taille de ces sprites, nous avons décidé de doubler nos pixels, ce qui signifie qu'un lutin de 16 x 16 fait désormais 32 x 32 pixels, et ainsi de suite. Depuis le début, nous redoublons d'efforts sur la création d'éléments au lieu de laisser le navigateur faire le plus gros du travail. Cette approche était simplement plus facile à implémenter, mais elle présentait également des avantages certains en termes d'apparence.

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 lutins 1 x 1 au lieu de doubler leur création côté création. À partir de là, CSS prendrait le relais et redimensionne le canevas lui-même. Nos analyses comparatives ont révélé que cette méthode peut être environ deux fois plus rapide que le rendu d'images plus grandes (doublées). Cependant, malheureusement, le redimensionnement CSS inclut l'anticrénelage, ce que nous n'avons pas pu éviter.

Options de redimensionnement de la toile
À gauche: des assets pixelisés sont doublés dans Photoshop. À droite: le redimensionnement CSS a ajouté un effet flou.

Cette approche constituait une véritable rupture dans notre jeu, car les pixels individuels sont très importants. Toutefois, si vous devez redimensionner votre canevas et que l'anticrénelage est adapté à votre projet, vous pouvez envisager cette approche pour des raisons de performances.

Astuces amusantes sur le canevas

Nous savons tous que <canvas> est la nouvelle popularité, mais parfois, les développeurs recommandent toujours d'utiliser le DOM. Si vous hésitez sur l'utilisation à utiliser, voici comment <canvas> nous a permis d'économiser beaucoup de temps et d'énergie.

Lorsqu'un ennemi est touché dans Onslaught! Arena, elle clignote en rouge et affiche brièvement une animation de douleur. Pour limiter le nombre de graphiques que nous avons dû créer, nous n'affichons que les ennemis avec la « douleur » orienté vers le bas. Cela semble acceptable dans le jeu et permet de gagner énormément de temps lors de la création des sprites. Toutefois, pour les monstres, il était bouleversant de voir un grand lutin (de 64 x 64 pixels ou plus) s'afficher de face ou de haut en bas, face au cadre de la douleur.

Une solution évidente consisterait à dessiner un cadre douloureux pour chaque responsable dans chacune des huit directions, mais cela aurait pris beaucoup de temps. Grâce à <canvas>, nous avons pu résoudre ce problème dans le code:

Vous avez subi des dégâts dans Asslaught ! Salle
Vous pouvez créer des effets intéressants à l'aide de context.globalCompositeOperation.

Nous allons d'abord dessiner le monstre dans un "tampon" <canvas> caché, le superposer en rouge, puis afficher le résultat à l'écran. Le code ressemble à ceci:

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

La boucle de jeu

Le développement de jeux présente des 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. Ainsi, le code d'initialisation peut ne rien faire d'autre qu'écouter les événements d'entrée. La logique d'un jeu est différente, car il est nécessaire de constamment se mettre à jour. Si, par exemple, un joueur n'a pas bougé, cela ne devrait 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 n'effectue aucune action immédiatement. Si un utilisateur appuie sur une touche dans une application Web classique, il est judicieux d'effectuer immédiatement l'action souhaitée. Mais dans un jeu, les choses doivent se passer dans l'ordre chronologique pour se dérouler correctement.

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 que tout a été calculé, il est temps de redessiner l'écran. Dans l'emplacement DOM, le navigateur gère ce soulèvement. Toutefois, lorsque vous utilisez <canvas>, il est nécessaire de redessiner manuellement chaque fois que quelque chose se produit (généralement, chaque image).

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 temporelle est le concept qui consiste à déplacer des lutins en fonction du temps écoulé depuis la dernière mise à jour de l'image. Cette technique permet à votre jeu de s'exécuter aussi vite que possible tout en veillant à ce que les lutins se déplacent à une vitesse constante.

Pour utiliser la modélisation temporelle, nous devons capturer le temps écoulé depuis le tracé du dernier frame. Nous devons améliorer la fonction update() de notre boucle de jeu pour effectuer ce suivi.

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 le temps écoulé, nous pouvons calculer jusqu'à quelle distance un lutin donné doit se déplacer à chaque image. Tout d'abord, nous devons effectuer le suivi de quelques éléments sur un objet luttant: 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;
};

En tenant compte de ces variables, voici comment déplacer une instance de la classe de lutin ci-dessus à l'aide d'une 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.

Contrôles

Les contrôles ont été la plus grande difficulté lors du développement Onslaught! dans l'Arena. La toute première démo ne prenait en charge que le clavier. Les joueurs ont déplacé le personnage principal à l'aide des touches fléchées et tiré dans la direction à laquelle il faisait face avec la barre d'espace. Bien que relativement intuitif et facile à prendre en main, ce jeu était presque impossible à jouer aux niveaux plus difficiles. Avec des dizaines d'ennemis et de projectiles qui volent sur le joueur à tout moment, il est impératif de pouvoir se faufiler entre les méchants tout en tirant dans n'importe quelle direction.

À des fins de comparaison avec des jeux similaires de son genre, nous avons ajouté la prise en charge de la souris pour contrôler un réticule de ciblage, que le personnage utiliserait pour lancer ses attaques. Le personnage peut toujours être déplacé avec le clavier, mais après ce changement, il pourrait se déclencher simultanément dans n'importe quelle direction à 360 degrés. Les joueurs inconditionnels appréciaient cette fonctionnalité, mais elle avait l'effet secondaire de frustrer les utilisateurs du pavé tactile.

Tué ! Fenêtre modale des commandes de l&#39;arène (obsolète)
Anciennes commandes ou fenêtre modale "comment jouer" dans Onslaught! dans l'arène.

Pour répondre aux besoins des utilisateurs du pavé tactile, nous avons rétabli les touches fléchées, ce qui permet cette fois de se déclencher dans le ou les sens où l'utilisateur appuie dessus. Alors que nous pensions nous adapter à tous les types de joueurs, nous avons aussi rendu notre jeu trop complexe, sans le savoir. À notre grande surprise, nous avons appris par la suite que certains joueurs n'étaient pas au courant des commandes facultatives de la souris (ou du clavier !) pour les attaques, malgré les modalités du tutoriel, qui ont été largement ignorées.

Tué ! Tutoriel sur les commandes de l&#39;arène
Les joueurs ignorent presque tout le tutoriel en superposition, mais ils préfèrent jouer et s'amuser.

Nous sommes également heureux d'avoir des fans européens, mais ils nous ont fait part de leur frustration face au fait qu'ils ne disposaient peut-être pas de claviers QWERTY classiques et qu'ils ne pouvaient pas utiliser les touches WASD pour les mouvements directionnels. Les gauchers ont exprimé des plaintes similaires.

Avec ce schéma de contrôle complexe que nous avons mis en œuvre, le jeu sur les appareils mobiles pose également problème. En effet, l'une de nos requêtes les plus courantes est de créer Onslaught! Arena est disponible sur Android, iPad et d'autres appareils tactiles (sans clavier). L'un des principaux points forts 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 (notamment les commandes et les performances).

Pour résoudre ces nombreux problèmes, nous avons commencé à tester une méthode de jeu à une seule entrée qui n'implique qu'une interaction avec la souris (ou le toucher). Les joueurs cliquent ou touchent l'écran, et le personnage principal se dirige vers l'emplacement où l'utilisateur appuie, 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);
}

Dans certaines situations, supprimer le facteur supplémentaire consistant à viser les ennemis peut simplifier le jeu, mais nous estimons que simplifier les choses pour le joueur présente de nombreux avantages. D'autres stratégies émergent, comme le fait de devoir placer le personnage à proximité d'ennemis dangereux pour les cibler, et la prise en charge des appareils tactiles est inestimable.

Audio

En matière de contrôles et de performances, l'un de nos plus grands problèmes lors du développement d'Onslaught! Arena était la balise <audio> d'HTML5. Le pire est sans doute la latence: dans presque tous les navigateurs, il existe un délai entre l'appel de .play() et la lecture réelle du son. Cela peut nuire à l'expérience des joueurs, en particulier s'ils jouent avec un jeu au rythme soutenu comme le nôtre.

Parmi d'autres problèmes, citons l'échec du déclenchement de l'événement "progress", qui pourrait entraîner le blocage indéfini du flux de chargement du jeu. C'est pourquoi nous avons adopté une méthode "fall-forward", qui consiste à passer en audio HTML5 en cas d'échec de chargement du contenu Flash. 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 prenne en charge 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 Ogg Vorbis, avec le code suivant:

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

Enregistrement des données

Impossible de jouer aux jeux d'arcade sans battre des records ! Nous savions que nous avions besoin que certaines de nos données de jeu soient conservées et que, même si nous aurions pu utiliser des cookies comme les cookies, nous voulions explorer les nouvelles technologies HTML5 amusantes. Les options ne manquent pas, comme le stockage local, le stockage de session et les bases de données Web SQL.

ALT_TEXT_HERE
Les meilleurs scores sont enregistrés, ainsi que votre position dans le jeu après chaque victoire.

Nous avons décidé d'utiliser localStorage, car il est nouveau, génial et facile à utiliser. Il prend en charge la sauvegarde de paires clé/valeur de base, ce qui est tout ce dont nous avons besoin dans notre jeu. 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 « gotchas » à connaître. Peu importe ce 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é

Le langage HTML5 est une solution incroyable à utiliser. La plupart des implémentations gèrent tout ce dont un développeur de jeux a besoin, des éléments graphiques à la sauvegarde de l'état du jeu. Bien qu'il existe des difficultés de plus en plus nombreuses (comme les problèmes de tag <audio>), les développeurs de navigateurs évoluent rapidement et les choses sont déjà aussi bonnes qu'elles ne le sont. L'avenir s'annonce radieux pour les jeux basés sur le HTML5.

Tué ! Arène avec un logo HTML5 masqué
Vous pouvez obtenir un bouclier HTML5 en saisissant "html5" lorsque vous jouez à Onslaught! dans l'arène.