Étude de cas : JAM with Chrome

Comment nous avons rendu l'interface utilisateur géniale

Introduction

JAM with Chrome est un projet musical Web créé par Google. JAM with Chrome permet à des utilisateurs du monde entier de former un groupe et de jouer en temps réel dans le navigateur. DinahMoe a repoussé les limites de ce qui était possible avec l'API Web Audio de Chrome. Notre équipe de Tool of North America a conçu l'interface pour jouer de la guitare, de la batterie et de votre ordinateur comme s'il s'agissait d'un instrument de musique.

Sous la direction créative du Google Creative Lab, l'illustrateur Rob Bailey a créé des illustrations complexes pour chacun des 19 instruments disponibles dans JAM. Sur la base de ces éléments, le directeur interactif Ben Tricklebank et notre équipe de conception chez Tool ont créé une interface simple et professionnelle pour chaque instrument.

Montage complet du jam

Comme chaque instrument est visuellement unique, le directeur technique de Tool, Bartek Drozdz, et moi-même les avons assemblés à l'aide de combinaisons d'images PNG, d'éléments CSS, SVG et Canvas.

De nombreux instruments devaient gérer différentes méthodes d'interaction (clics, glissements et pincements, par exemple, toutes les actions que vous attendez d'un instrument), tout en conservant l'interface avec le moteur audio de DinahMoe. Nous avons constaté que nous avions besoin de plus que des événements mouseup et mousedown de JavaScript pour offrir une expérience de jeu de qualité.

Pour gérer toutes ces variations, nous avons créé un élément "Scène" qui couvrait la zone de jeu, et qui gérait les clics, les glissements et les coups de plectre sur tous les différents instruments.

Étape

Le contrôleur Stage nous permet de configurer la fonction sur un instrument. Par exemple, en ajoutant différentes parties des instruments avec lesquels l'utilisateur interagira. À mesure que nous ajoutons des interactions (comme un "clic"), nous pouvons les ajouter au prototype de l'étape.

function Stage(el) {

  // Grab the elements from the dom
  this.el = document.getElementById(el);
  this.elOutput = document.getElementById("output-1");

  // Find the position of the stage element
  this.position();

  // Listen for events
  this.listeners();

  return this;
}

Stage.prototype.position = function() {
  // Get the position
};

Stage.prototype.offset = function() {
  // Get the offset of the element in the window
};

Stage.prototype.listeners = function() {
  // Listen for Resizes or Scrolling
  // Listen for Mouse events
};

Obtenir l'élément et la position de la souris

Notre première tâche consiste à traduire les coordonnées de la souris dans la fenêtre du navigateur pour qu'elles soient relatives à notre élément Stage. Pour ce faire, nous avons dû tenir compte de l'emplacement de notre scène sur la page.

Comme nous devons déterminer la position de l'élément par rapport à l'ensemble de la fenêtre, et non seulement par rapport à son élément parent, la tâche est un peu plus complexe que de simplement examiner les éléments offsetTop et offsetLeft. La méthode la plus simple consiste à utiliser getBoundingClientRect, qui indique la position par rapport à la fenêtre, tout comme les événements de souris. Elle est compatible avec les navigateurs plus récents.

Stage.prototype.offset = function() {
  var _x, _y,
      el = this.el;

  // Check to see if bouding is available
  if (typeof el.getBoundingClientRect !== "undefined") {

    return el.getBoundingClientRect();

  } else {
    _x = 0;
    _y = 0;

    // Go up the chain of parents of the element
    // and add their offsets to the offset of our Stage element

    while (el && !isNaN( el.offsetLeft ) && !isNaN( el.offsetTop ) ) {
      _x += el.offsetLeft;
      _y += el.offsetTop;
      el = el.offsetParent;
    }

    // Subtract any scrolling movment
    return {top: _y - window.scrollY, left: _x - window.scrollX};
  }
};

Si getBoundingClientRect n'existe pas, nous avons une fonction simple qui ne fait que cumuler les décalages, en remontant la chaîne des parents de l'élément jusqu'au corps. Nous soustrayons ensuite la distance de défilement de la fenêtre pour obtenir la position par rapport à la fenêtre. Si vous utilisez jQuery, la fonction offset() gère parfaitement la complexité de la détermination de l'emplacement sur les différentes plates-formes, mais vous devrez toujours soustraire la quantité de défilement.

Chaque fois que la page est cranée ou redimensionnée, il est possible que la position de l'élément ait changé. Nous pouvons écouter ces événements et vérifier à nouveau la position. Ces événements sont déclenchés de nombreuses fois lors d'un défilement ou d'un redimensionnement typique. Par conséquent, dans une application réelle, il est probablement préférable de limiter la fréquence de vérification de la position. Il existe de nombreuses façons de procéder, mais HTML5 Rocks propose un article sur le débouncing des événements de défilement à l'aide de requestAnimationFrame, qui convient parfaitement ici.

Avant de gérer la détection de contact, ce premier exemple ne renvoie que les coordonnées x et y relatives chaque fois que la souris est déplacée dans la zone de l'étape.

Stage.prototype.listeners = function() {
  var output = document.getElementById("output");

  this.el.addEventListener('mousemove', function(e) {
      // Subtract the elements position from the mouse event's x and y
      var x = e.clientX - _self.positionLeft,
          y = e.clientY - _self.positionTop;

      // Print out the coordinates
      output.innerHTML = (x + "," + y);

  }, false);
};

Pour commencer à surveiller le mouvement de la souris, nous allons créer un objet Stage et lui transmettre l'ID de la div que nous voulons utiliser comme scène.

//-- Create a new Stage object, for a div with id of "stage"
var stage = new Stage("stage");

Détection de contact simple

Dans JAM avec Chrome, toutes les interfaces d'instruments ne sont pas complexes. Nos pads de boîte à rythmes ne sont que de simples rectangles, ce qui permet de détecter facilement si un clic se trouve dans leurs limites.

Boîte à rythmes

Nous allons commencer par les rectangles, puis configurer quelques types de formes de base. Chaque objet de forme doit connaître ses limites et être en mesure de vérifier si un point se trouve à l'intérieur.

function Rect(x, y, width, height) {
  this.x = x;
  this.y = y;
  this.width = width;
  this.height = height;
  return this;
}

Rect.prototype.inside = function(x, y) {
  return x >= this.x && y >= this.y
      && x <= this.x + this.width
      && y <= this.y + this.height;
};

Chaque nouveau type de forme que nous ajoutons nécessitera une fonction dans notre objet Stage pour l'enregistrer en tant que zone de cible.

Stage.prototype.addRect = function(id) {
  var el = document.getElementById(id),
      rect = new Rect(
        el.offsetLeft,
        el.offsetTop,
        el.offsetWidth,
        el.offsetHeight
      );

  rect.el = el;

  this.hitZones.push(rect);
  return rect;
};

Lors des événements de souris, chaque instance de forme vérifie si les coordonnées X et Y de la souris transmises sont un coup pour elle et renvoie la valeur "true" ou "false".

Nous pouvons également ajouter une classe "active" à l'élément de scène, qui remplacera le curseur de la souris par un pointeur lorsque l'utilisateur survolera le carré.

this.el.addEventListener ('mousemove', function(e) {
  var x = e.clientX - _self.positionLeft,
      y = e.clientY - _self.positionTop;

  _self.hitZones.forEach (function(zone){
    if (zone.inside(x, y)) {
      // Add class to change colors
      zone.el.classList.add('hit');
      // change cursor to pointer
      this.el.classList.add('active');
    } else {
      zone.el.classList.remove('hit');
      this.el.classList.remove('active');
    }
  });

}, false);

Autres formes

À mesure que les formes deviennent plus complexes, les calculs pour déterminer si un point se trouve à l'intérieur d'elles deviennent plus complexes. Toutefois, ces équations sont bien établies et documentées en détail dans de nombreux endroits en ligne. Certains des meilleurs exemples JavaScript que j'ai vus proviennent de la bibliothèque de géométrie de Kevin Lindsey.

Heureusement, lors de la création de JAM avec Chrome, nous n'avons jamais eu besoin d'aller au-delà des cercles et des rectangles, en nous appuyant sur des combinaisons de formes et de superpositions pour gérer toute complexité supplémentaire.

Formes de batterie

Cercles

Pour vérifier si un point se trouve dans un cylindre circulaire, nous devons créer une forme de base circulaire. Bien qu'il soit assez semblable au rectangle, il dispose de ses propres méthodes pour déterminer les limites et vérifier si le point se trouve à l'intérieur du cercle.

function Circle(x, y, radius) {
  this.x = x;
  this.y = y;
  this.radius = radius;
  return this;
}

Circle.prototype.inside = function(x, y) {
  var dx = x - this.x,
      dy = y - this.y,
      r = this.radius;
  return dx * dx + dy * dy <= r * r;
};

Au lieu de changer la couleur, l'ajout de la classe de contact déclenche une animation CSS3. La taille de l'arrière-plan nous permet de mettre rapidement à l'échelle l'image du tambour, sans affecter sa position. Vous devrez ajouter les préfixes d'autres navigateurs pour qu'ils fonctionnent avec eux (-moz, -o et -ms). Vous pouvez également ajouter une version sans préfixe.

#snare.hit{
  { % mixin animation: drumHit .15s linear infinite; % }
}

@{ % mixin keyframes drumHit % } {
  0%   { background-size: 100%;}
  10%  { background-size: 95%; }
  30%  { background-size: 97%; }
  50%  { background-size: 100%;}
  60%  { background-size: 98%; }
  70%  { background-size: 100%;}
  80%  { background-size: 99%; }
  100% { background-size: 100%;}
}

Strings

Notre fonction GuitarString reçoit un ID de canevas et un objet Rect, puis dessine une ligne au centre de ce rectangle.

function GuitarString(rect) {
  this.x = rect.x;
  this.y = rect.y + rect.height / 2;
  this.width = rect.width;
  this._strumForce = 0;
  this.a = 0;
}

Lorsque nous voulons qu'il vibre, nous appelons notre fonction de pincement pour mettre la corde en mouvement. Chaque frame que nous lisons réduit légèrement la force avec laquelle la corde a été pincée et augmente un compteur qui fait osciller la corde d'avant en arrière.

GuitarString.prototype.strum = function() {
  this._strumForce = 5;
};

GuitarString.prototype.render = function(ctx, canvas) {
  ctx.strokeStyle = "#000000";
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(this.x, this.y);
  ctx.bezierCurveTo(
      this.x, this.y + Math.sin(this.a) * this._strumForce,
      this.x + this.width, this.y + Math.sin(this.a) * this._strumForce,
      this.x + this.width, this.y);
  ctx.stroke();

  this._strumForce *= 0.99;
  this.a += 0.5;
};

Intersections et pincements

La zone de détection de la chaîne ne sera plus qu'un cadre. Cliquez dans cette zone pour déclencher l'animation de la chaîne. Mais qui veut cliquer sur une guitare ?

Pour ajouter des accords, nous devons vérifier l'intersection de la zone des cordes et de la ligne que la souris de l'utilisateur suit.

Pour obtenir une distance suffisante entre la position précédente et actuelle de la souris, nous devons ralentir la fréquence à laquelle nous recevons les événements de mouvement de la souris. Pour cet exemple, nous allons simplement définir un indicateur pour ignorer les événements mousemove pendant 50 millisecondes.

document.addEventListener('mousemove', function(e) {
  var x, y;

  if (!this.dragging || this.limit) return;

  this.limit = true;

  this.hitZones.forEach(function(zone) {
    this.checkIntercept(
      this.prev[0],
      this.prev[1],
      x,
      y,
      zone
    );
  });

  this.prev = [x, y];

  setInterval(function() {
    this.limit = false;
  }, 50);
};

Nous allons ensuite utiliser un code d'intersection écrit par Kevin Lindsey pour voir si la ligne de mouvement de la souris croise le milieu de notre rectangle.

Rect.prototype.intersectLine = function(a1, a2, b1, b2) {
  //-- http://www.kevlindev.com/gui/math/intersection/Intersection.js
  var result,
      ua_t = (b2.x - b1.x) * (a1.y - b1.y) - (b2.y - b1.y) * (a1.x - b1.x),
      ub_t = (a2.x - a1.x) * (a1.y - b1.y) - (a2.y - a1.y) * (a1.x - b1.x),
      u_b = (b2.y - b1.y) * (a2.x - a1.x) - (b2.x - b1.x) * (a2.y - a1.y);

  if (u_b != 0) {
    var ua = ua_t / u_b;
    var ub = ub_t / u_b;

    if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) {
      result = true;
    } else {
      result = false; //-- No Intersection
    }
  } else {
    if (ua_t == 0 || ub_t == 0) {
      result = false; //-- Coincident
    } else {
      result = false; //-- Parallel
    }
  }

  return result;
};

Enfin, nous allons ajouter une fonction pour créer un instrument à cordes. Il crée l'étape, configure un certain nombre de chaînes et obtient le contexte du canevas sur lequel il sera dessiné.

function StringInstrument(stageID, canvasID, stringNum){
  this.strings = [];
  this.canvas = document.getElementById(canvasID);
  this.stage = new Stage(stageID);
  this.ctx = this.canvas.getContext('2d');
  this.stringNum = stringNum;

  this.create();
  this.render();

  return this;
}

Nous allons ensuite positionner les zones de détection des chaînes, puis les ajouter à l'élément "Stage".

StringInstrument.prototype.create = function() {
  for (var i = 0; i < this.stringNum; i++) {
    var srect = new Rect(10, 90 + i * 15, 380, 5);
    var s = new GuitarString(srect);
    this.stage.addString(srect, s);
    this.strings.push(s);
  }
};

Enfin, la fonction de rendu de StringInstrument parcourra toutes nos chaînes et appellera leurs méthodes de rendu. Il s'exécute en permanence, aussi rapidement que requestAnimationFrame le juge approprié. Pour en savoir plus sur requestAnimationFrame, consultez l'article requestAnimationFrame pour une animation intelligente de Paul Irish.

Dans une application réelle, vous pouvez définir un indicateur lorsqu'aucune animation n'est en cours pour arrêter de dessiner un nouveau cadre de canevas.

StringInstrument.prototype.render = function() {
  var _self = this;

  requestAnimFrame(function(){
    _self.render();
  });

  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

  for (var i = 0; i < this.stringNum; i++) {
    this.strings[i].render(this.ctx);
  }
};

Conclusion

L'utilisation d'un élément de scène commun pour gérer toutes nos interactions n'est pas sans inconvénients. Il est plus complexe en termes de calcul, et les événements du pointeur du curseur sont limités sans ajouter de code supplémentaire pour les modifier. Toutefois, pour JAM avec Chrome, les avantages de pouvoir extraire les événements de souris des éléments individuels ont très bien fonctionné. Nous avons ainsi pu tester davantage la conception de l'interface, passer d'une méthode d'animation des éléments à une autre, utiliser le format SVG pour remplacer les images de formes de base, désactiver facilement les zones de clic, et plus encore.

Pour voir les batteries et les percussions en action, créez votre propre JAM et sélectionnez les batteries standards ou la guitare électrique clean classique.

Logo Jam