Caso de éxito: JAM con Chrome

Cómo hicimos que la IU fuera espectacular

Introducción

JAM con Chrome es un proyecto musical basado en la Web creado por Google. JAM con Chrome permite a personas de todo el mundo formar una banda y JAM en tiempo real dentro del navegador. DinahMoe expandió los límites de lo posible con la API de Web Audio de Chrome, y nuestro equipo de Tool of North America elaboró la interfaz para tocar, tocar la batería y tocar la computadora como si fuera un instrumento musical.

Con la dirección creativa de Google Creative Lab, el ilustrador Rob Bailey creó ilustraciones complejas para cada uno de los 19 instrumentos disponibles para JAM. Con esa ayuda, el director interactivo Ben Tricklebank y nuestro equipo de diseño de Tool crearon una interfaz fácil y profesional para cada instrumento.

Montaje de frascos completos

Como cada instrumento es visualmente único, Bartek Drozdz, el director técnico de Tool, y yo los unimos mediante combinaciones de imágenes PNG, CSS, SVG y Canvas.

Muchos de los instrumentos tuvieron que manejar distintos métodos de interacción (como clics, arrastres y rasgueos; es decir, todas las cosas que esperarías hacer con un instrumento) mientras mantenían igual la interfaz con el motor de sonido de DinahMoe. Descubrimos que necesitábamos algo más que solo el mouse-up y mousedown de JavaScript para poder proporcionar una experiencia de juego atractiva.

Para afrontar toda esta variación, creamos un elemento «Stage» que cubrió el área de juego, el cual cubrió los clics, los arrastres y los rasgueos en todos los diferentes instrumentos.

El escenario

El escenario es el controlador que usamos para configurar la función en un instrumento. Por ejemplo, agregar diferentes partes de los instrumentos con las que el usuario interactuará. A medida que agreguemos más interacciones (como un "hit"), podemos agregarlas al prototipo de Stage.

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

Cómo obtener el elemento y la posición del mouse

Nuestra primera tarea es traducir las coordenadas del mouse en la ventana del navegador para que estén relacionadas con nuestro elemento Stage. Para ello, tuvimos que tener en cuenta en qué parte de la página se encuentra la Etapa.

Como necesitamos encontrar dónde el elemento es relativo a toda la ventana, no solo a su elemento principal, es un poco más complicado que solo mirar los elementos offsetTop y offsetLeft. La opción más fácil es usar getBoundingClientRect, que otorga la posición relativa a la ventana, al igual que los eventos del mouse, y es compatible con los navegadores más nuevos.

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 no existe, tenemos una función simple que agregará desplazamientos hacia arriba en la cadena de los elementos superiores hasta que llegue al cuerpo. Luego, restamos el desplazamiento de la ventana para obtener la posición relativa a ella. Si usas jQuery, la función offset() maneja muy bien la complejidad de determinar la ubicación en todas las plataformas, pero, de todos modos, deberás restar la cantidad desplazada.

Cada vez que el usuario se desplaza o cambia el tamaño de la página, es posible que la posición del elemento cambie. Podemos escuchar estos eventos y volver a verificar la posición. Estos eventos se activan muchas veces con un desplazamiento o un cambio de tamaño típicos. Por lo tanto, en una aplicación real, probablemente sea mejor limitar la frecuencia con la que se vuelve a verificar la posición. Existen muchas maneras de hacerlo, pero HTML5 Rocks tiene un artículo para repeler eventos de desplazamiento utilizando requestAnimationFrame, que funcionará bien en este caso.

Antes de controlar cualquier detección de hits, en este primer ejemplo solo se mostrarán los valores x e y relativos cada vez que el mouse se mueva en el área del escenario.

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

Para comenzar a observar el movimiento del mouse, crearemos un nuevo objeto Stage y le pasaremos el ID del elemento div que queremos usar como Stage.

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

Detección de hits simple

En JAM con Chrome, no todas las interfaces del instrumento son complejas. Nuestros paneles de caja de ritmo son simplemente rectángulos simples, lo que facilita la detección si un clic está dentro de sus límites.

Caja de ritmos

Comenzando con los rectángulos, configuraremos algunos tipos base de formas. Cada objeto de forma debe conocer sus límites y tener la capacidad de comprobar si un punto se encuentra dentro de él.

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

Cada tipo de forma nuevo que agreguemos necesitará una función dentro de nuestro objeto Stage para registrarlo como una zona de hit.

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

En los eventos del mouse, cada instancia de forma se encargará de verificar si el x y el eje y del mouse que se pasan son aciertos y muestran verdadero o falso.

También podemos agregar una clase "activa" al elemento de la etapa que cambiará el cursor del mouse para que sea un puntero cuando se desplace sobre el cuadrado.

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

Más formas

A medida que las formas se vuelven más complejas, se vuelve más complejo calcular si hay un punto dentro de ellas. Sin embargo, estas ecuaciones están bien establecidas y documentadas con gran detalle en muchos lugares en línea. Algunos de los mejores ejemplos de JavaScript que vi son de la biblioteca de geometría de Kevin Lindsey.

Por suerte, cuando compilamos JAM con Chrome, nunca tuvimos que ir más allá de los círculos y rectángulos, ya que nos basamos en combinaciones de formas y capas para manejar cualquier complejidad adicional.

Formas de tambores

Círculos

Para comprobar si un punto está dentro de un tambor circular, tendremos que crear una forma de base circular. Aunque es bastante similar al rectángulo, tendrá sus propios métodos para determinar los límites y comprobar si el punto está dentro del círculo.

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

En lugar de cambiar el color, agregar la clase de hit activará una animación CSS3. El tamaño de fondo nos brinda una buena forma de escalar rápidamente la imagen del tambor, sin afectar su posición. Para realizar este trabajo, deberás agregar los prefijos de otros navegadores (-moz, -o y -ms) y es posible que también quieras agregar una versión sin prefijo.

#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

Nuestra función GuitarString tomará un ID de lienzo y un objeto Rect y trazará una línea a través del centro de ese rectángulo.

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

Cuando queremos que vibre, llamaremos a nuestra función de rasgueo para colocar la cuerda en movimiento. Cada fotograma que renderizamos reducirá ligeramente la fuerza con la que se rasgueó y aumentará un contador que hará que la cadena oscile hacia atrás y hacia adelante.

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

Intersecciones y rasgueo

Nuestra área de impacto para la cadena será nuevamente un cuadro. Si haces clic dentro de ese cuadro, se debería activar la animación de la cadena. Pero ¿quién quiere tocar una guitarra?

Para agregar rasgueo, necesitamos verificar la intersección de la casilla de cadenas y la línea que recorre el mouse del usuario.

Para obtener una distancia suficiente entre la posición anterior y actual del mouse, tendremos que disminuir la velocidad a la que obtenemos los eventos de movimiento del mouse. En este ejemplo, simplemente estableceremos un indicador para ignorar los eventos mousemove durante 50 milisegundos.

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

A continuación, necesitaremos utilizar el código de intersección que escribió Kevin Lindsey para ver si la línea de movimiento del mouse se interseca con el medio de nuestro rectángulo.

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

Por último, agregaremos una nueva función para crear un instrumento de string. Esto creará la nueva etapa, configurará una serie de cadenas y obtendrá el contexto del lienzo en el que se dibujará.

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

A continuación, posicionaremos las áreas de impacto de las cadenas y las agregaremos al elemento 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);
  }
};

Por último, la función de renderización de StringInstrument recorre todas nuestras cadenas y llama a sus métodos de renderización. Se ejecuta todo el tiempo, con la rapidez que creas conveniente. Puede obtener más información acerca de requestAnimationFrame en el artículo requestAnimationFrame para la animación inteligente de Paul Ireland.

En una aplicación real, es posible que desees establecer una marca cuando no se produzca ninguna animación para dejar de dibujar un nuevo marco de lienzo.

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

Conclusión

Tener un elemento Stage común para manejar toda nuestra interacción no carece de sus desventajas. Es más complejo en términos de procesamiento, y los eventos de puntero del cursor están limitados sin agregar código adicional para cambiarlos. Sin embargo, en el caso de JAM con Chrome, los beneficios de poder abstraer eventos del mouse de los elementos individuales funcionaron realmente bien. Nos permitió experimentar más con el diseño de la interfaz, cambiar entre métodos de animación de elementos, usar SVG para reemplazar imágenes de formas básicas, inhabilitar fácilmente las áreas de impacto y mucho más.

Para ver la batería y los golpes en acción, inicia tu propia JAM y selecciona Standard Drums o Classic Clean Electric Guitar.

Logotipo de Jam