Cómo logramos que la IU fuera genial
Introducción
JAM with Chrome es un proyecto musical basado en la Web creado por Google. JAM con Chrome permite que personas de todo el mundo formen una banda y JAM en tiempo real dentro del navegador. DinahMoe llevó al límite lo que era posible con la API de Web Audio de Chrome. Nuestro equipo de Tool of North America creó la interfaz para rasguear, tocar la batería y usar la computadora como si fuera un instrumento musical.
Con la dirección creativa de Google Creative Lab, el ilustrador Rob Bailey creó ilustraciones intrincadas para cada uno de los 19 instrumentos disponibles para JAM. A partir de ellos, el director interactivo Ben Tricklebank y nuestro equipo de diseño de Tool crearon una interfaz fácil y profesional para cada instrumento.
Como cada instrumento es visualmente único, el director técnico de Tool, Bartek Drozdz, y yo los unimos con combinaciones de imágenes PNG, CSS, SVG y elementos de lienzo.
Muchos de los instrumentos debían controlar diferentes métodos de interacción (como clics, arrastres y rasgueos, todo lo que se espera hacer con un instrumento) y, al mismo tiempo, mantener la interfaz con el motor de sonido de DinahMoe igual. Descubrimos que necesitábamos más que solo los eventos mouseup y mousedown de JavaScript para poder brindar una experiencia de juego increíble.
Para abordar toda esta variación, creamos un elemento “Escenario” que cubría el área de juego y controlaba los clics, los arrastres y los rasgueos en todos los diferentes instrumentos.
El escenario
Stage es nuestro controlador que usamos para configurar la función en un instrumento. Por ejemplo, agregar diferentes partes de los instrumentos con los que interactuará el usuario. A medida que agregamos más interacciones (como un “hit”), podemos agregarlas al prototipo de la etapa.
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 sean relativas a nuestro elemento de escenario. Para ello, tuvimos que tener en cuenta dónde se encuentra nuestro escenario en la página.
Como necesitamos encontrar dónde se encuentra el elemento en relación con toda la ventana, no solo con su elemento superior, 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 proporciona la posición en relación con la ventana, al igual que los eventos del mouse, y es compatible con navegadores más recientes.
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 no existe getBoundingClientRect, tenemos una función simple que solo sumará los desplazamientos, subiendo por la cadena de los elementos superiores hasta llegar al cuerpo. Luego, restamos la cantidad de desplazamiento de la ventana para obtener la posición en relación con ella. Si usas jQuery, la función offset() hace un gran trabajo para controlar la complejidad de determinar la ubicación en todas las plataformas, pero aún deberás restar el importe desplazado.
Cada vez que se desplaza la página o se cambia su tamaño, es posible que haya cambiado la posición del elemento. Podemos detectar estos eventos y volver a verificar la posición. Estos eventos se activan muchas veces en un desplazamiento o cambio de tamaño típico, por lo que, en una aplicación real, lo mejor es limitar la frecuencia con la que vuelves a verificar la posición. Hay muchas formas de hacerlo, pero HTML5 Rocks tiene un artículo para desactivar los eventos de desplazamiento con requestAnimationFrame, que funcionará bien aquí.
Antes de controlar cualquier detección de hit, este primer ejemplo solo mostrará las coordenadas x e y relativas cada vez que se mueva el mouse 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 div que queremos usar como nuestro 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 de los instrumentos son complejas. Nuestros pads de la caja de ritmos son solo rectángulos simples, lo que facilita detectar si un clic se encuentra dentro de sus límites.
Comenzaremos con los rectángulos y configuraremos algunos tipos básicos de formas. Cada objeto de forma debe conocer sus límites y tener la capacidad de verificar si un punto está 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 nueva que agreguemos necesitará una función dentro de nuestro objeto Stage para registrarla como zona de impacto.
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 controlará si las coordenadas x e y pasadas del mouse son un hit para ella y mostrará un valor verdadero o falso.
También podemos agregar una clase "active" al elemento del escenario que cambiará el cursor del mouse para que sea un puntero cuando se coloque el cursor 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 complicadas, la matemática para determinar si un punto está dentro de ellas se vuelve más compleja. Sin embargo, estas ecuaciones están bien establecidas y documentadas en muchos lugares en línea. Algunos de los mejores ejemplos de JavaScript que he visto son de la biblioteca de geometría de Kevin Lindsey.
Afortunadamente, cuando creamos JAM con Chrome, nunca tuvimos que ir más allá de los círculos y los rectángulos, y nos basamos en combinaciones de formas y capas para manejar cualquier complejidad adicional.
Círculos
Para comprobar si un punto está dentro de un tambor circular, necesitaremos crear una forma de base circular. Aunque es bastante similar al rectángulo, tendrá sus propios métodos para determinar los límites y verificar 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 del fondo nos brinda una buena manera de escalar rápidamente la imagen del tambor, sin afectar su posición. Deberás agregar los prefijos de otros navegadores para que funcionen con ellos (-moz, -o y -ms). También te recomendamos que agregues 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 dibujará una línea en el 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 queramos que vibre, llamaremos a nuestra función de rasgueo para poner la cuerda en movimiento. Cada fotograma que rendericemos reducirá ligeramente la fuerza con la que se tocó y aumentará un contador que hará que la cuerda oscile hacia adelante y hacia atrás.
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 rasgueos
Nuestro área de hit para la cadena volverá a ser un cuadro. Si haces clic en ese cuadro, se debería activar la animación de la cadena. Pero ¿quién quiere hacer clic en una guitarra?
Para agregar rasgueos, debemos verificar la intersección del cuadro de cuerdas y la línea por la que se mueve el mouse del usuario.
Para obtener suficiente distancia entre la posición anterior y la actual del mouse, tendremos que reducir la velocidad a la que recibimos los eventos de movimiento del mouse. En este ejemplo, simplemente estableceremos una marca para ignorar los eventos de 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 usar un código de intersección que escribió Kevin Lindsey para ver si la línea de movimiento del mouse se cruza 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 cadena. Creará el nuevo escenario, 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, luego, 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 recorrerá todas nuestras cadenas y llamará a sus métodos de renderización. Se ejecuta todo el tiempo, con la mayor rapidez que considere requestAnimationFrame. Puedes obtener más información sobre requestAnimationFrame en el artículo de Paul Irish requestAnimationFrame para animaciones inteligentes.
En una aplicación real, es posible que desees establecer una marca cuando no se produzca una 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 de Stage común para controlar todas nuestras interacciones no está exento de inconvenientes. Es más complejo en términos de procesamiento, y los eventos del puntero del cursor son limitados sin agregar código adicional para cambiarlos. Sin embargo, para JAM con Chrome, los beneficios de poder abstraer los eventos del mouse de los elementos individuales funcionaron muy bien. Nos permite 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 áreas de impacto con facilidad y mucho más.
Para ver los elementos de batería y Stings en acción, crea tu propia JAM y selecciona Batería estándar o Guitarra eléctrica limpia clásica.