Étude de cas : JAM with Chrome

Comment l'interface utilisateur a-t-elle été décisive ?

Présentation

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

Avec la direction créative de Google Creative Lab, l'illustrateur Rob Bailey a créé des illustrations sophistiquées pour chacun des 19 instruments disponibles pour la JAM with JAM. En s'appuyant sur celles-ci, le directeur interactif Ben Tricklebank et notre équipe de conception chez Tool ont créé une interface conviviale et professionnelle pour chaque instrument.

Montage complet d'un Jam

Chaque instrument étant visuellement unique, Bartek Drozdz, le directeur technique d'Tool, et moi-même les avons assemblés en combinant des images PNG, CSS, SVG et Canvas.

De nombreux instruments devaient gérer différentes méthodes d'interaction (comme les clics, les mouvements et les cordes, tout ce que l'on s'attendait à faire avec un instrument) tout en conservant l'interface avec le moteur sonore de DinahMoe. Nous avons constaté qu'il nous fallait bien plus que les fonctions JavaScript de survol et de survol pour offrir une expérience de jeu agréable.

Pour faire face à toutes ces variations, nous avons créé un élément "Stage" (scène) qui couvrait la zone jouable, gérant les clics, les mouvements et les cordes sur les différents instruments.

La scène

La scène est le contrôleur qui nous permet de configurer les fonctions d'un instrument. Il peut s'agir, par exemple, d'ajouter différentes parties des instruments avec lesquels l'utilisateur interagira. À mesure que nous ajoutons des interactions (comme un "appel"), nous pouvons les ajouter au prototype de la scène.

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

La première tâche consiste à traduire les coordonnées de la souris dans la fenêtre du navigateur afin qu'elles soient relatives à l'élément de l'espace de création. Pour ce faire, nous devions tenir compte de la position de l'étape sur la page.

Comme nous devons trouver la position de l'élément par rapport à l'ensemble de la fenêtre, et pas seulement à son élément parent, il est légèrement plus compliqué que de simplement examiner les éléments offsetTop et offsetLeft. L'option la plus simple consiste à utiliser getBoundingClientRect, qui donne la position par rapport à la fenêtre, tout comme les événements de souris. Cette méthode est bien compatible avec les navigateurs 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 disposons d'une fonction simple qui additionne simplement les décalages en remontant la chaîne des parents de l'élément jusqu'à atteindre le corps. Ensuite, nous soustrayons le degré de défilement de la fenêtre pour obtenir sa position par rapport à celle-ci. Si vous utilisez jQuery, la fonction offset() permet de gérer efficacement la complexité liée à la détermination de la position sur les différentes plates-formes, mais vous devrez quand même soustraire la quantité de défilement.

Chaque fois que l'utilisateur fait défiler ou redimensionne la page, 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 plusieurs fois lors d'un défilement ou d'un redimensionnement classique. Par conséquent, dans une application réelle, il est probablement préférable de limiter la fréquence de revérification de la position. Il existe de nombreuses façons de procéder, mais HTML5 Rocks propose un article expliquant comment éviter les événements de défilement à l'aide de requestAnimationFrame, qui convient parfaitement dans ce cas de figure.

Avant de traiter la détection des hits, ce premier exemple affiche simplement les valeurs x et y relatives chaque fois que la souris est déplacée dans la zone de l'espace de création.

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 à observer les mouvements de la souris, nous allons créer un objet "Stage" et lui transmettre l'ID de l'élément div qui servira d'espace de création.

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

Détection de clics simple

Dans JAM avec Chrome, toutes les interfaces des instruments ne sont pas complexes. Les pads de notre machine à tambour ne sont que de simples rectangles, ce qui permet de détecter facilement si un clic se trouve dans les limites.

Boîte à rythmes

En commençant par les rectangles, nous allons configurer certains types de formes de base. Chaque objet de forme doit connaître ses limites et pouvoir vérifier si un point se trouve à l'intérieur de celui-ci.

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 requiert une fonction dans l'objet "Stage" pour l'enregistrer en tant que zone d'interaction.

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

Pour les événements de souris, chaque instance de forme peut vérifier si les coordonnées x et y de la souris transmises sont un appel et renvoie la valeur "true" ou "false".

Nous pouvons également ajouter une classe "active" à l'élément de l'espace de création, qui transforme le curseur de la souris en pointeur lorsque l'utilisateur pointe sur 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 se compliquent, les calculs permettant de déterminer si un point se trouve à l'intérieur d'elles deviennent de plus en plus complexes. Cependant, ces équations sont bien établies et documentées avec beaucoup de détails en 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, en créant 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 calques pour gérer toute complexité supplémentaire.

Formes de tambour

Cercles

Pour vérifier si un point se trouve à l'intérieur d'un tambour circulaire, nous devons créer une forme circulaire à la base. Bien qu'il soit assez similaire au rectangle, il aura 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 modifier la couleur, l'ajout de la classe d'appel déclenche une animation CSS3. La taille de l'arrière-plan nous permet de redimensionner rapidement l'image du tambour, sans modifier sa position. Pour ce faire, vous devez ajouter les préfixes des autres navigateurs (-moz, -o et -ms) et éventuellement 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 prend un ID de canevas et un objet Rect, puis trace 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;
}

Pour la faire vibrer, nous appelons notre fonction strum pour mettre la corde en mouvement. Chaque image rendue réduit la force avec laquelle elle a été frappée légèrement et augmente un compteur qui fait osciller la chaîne 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 prise de notes

La zone d'interaction pour la chaîne sera à nouveau une boîte. Le fait de cliquer dans cette zone doit déclencher l'animation de la chaîne. Mais qui veut cliquer sur une guitare ?

Pour ajouter du strumming, nous devons vérifier l'intersection de la case des chaînes et de la ligne par laquelle la souris de l'utilisateur se déplace.

Pour obtenir une distance suffisante entre la position précédente et la position actuelle de la souris, nous devons ralentir la fréquence à laquelle nous obtenons les événements de déplacement 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 maintenant devoir nous appuyer sur un code d'intersection écrit par Kevin Lindsey pour voir si le mouvement de la souris traverse 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;
};

Pour finir, nous allons ajouter une fonction pour créer un instrument à chaîne. Cela créera la scène, configurera un certain nombre de chaînes et obtiendra le contexte du canevas qui y 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 maintenant positionner les zones d'interaction 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" passe en boucle toutes nos chaînes et appelle leurs méthodes de rendu. Il s'exécute en permanence, dès que requestAnimationFrame le juge nécessaire. Pour en savoir plus sur requestAnimationFrame, lisez l'article de Paul Irish (requestAnimationFrame for smart animating).

Dans une application réelle, il peut être judicieux de définir un indicateur en l'absence d'animation pour arrêter l'affichage d'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

Disposer d'un élément Stage commun pour gérer toutes nos interactions n'est pas sans inconvénients. Il est plus complexe du point de vue informatique, et les événements de pointeur de curseur sont limités sans ajouter de code supplémentaire pour les modifier. Toutefois, pour JAM with Chrome, la possibilité d'extraire les événements de souris des éléments individuels est très efficace. Cela nous permet d'expérimenter davantage la conception de l'interface, de passer d'une méthode d'animation à une autre, d'utiliser le format SVG pour remplacer les images de formes de base, de désactiver facilement les zones d'interaction, etc.

Pour voir la batterie et les piqûres, démarrez votre propre JAM et sélectionnez la batterie standard ou la guitare électrique classique Clean.

Logo Jam