Como fizemos a interface ser incrível
Introdução
O JAM with Chrome é um projeto musical criado pelo Google. O JAM com o Chrome permite que pessoas de todo o mundo formem uma banda e JAM em tempo real no navegador. O DinahMoe ultrapassou os limites do que era possível com a API Web Audio do Chrome. Nossa equipe da Tool of North America criou a interface para tocar seu computador como se fosse um instrumento musical.
Com a direção criativa do Google Creative Lab, o ilustrador Rob Bailey criou ilustrações complexas para cada um dos 19 instrumentos disponíveis para o JAM. Com base nisso, o diretor interativo Ben Tricklebank e nossa equipe de design da Tool criaram uma interface fácil e profissional para cada instrumento.
Como cada instrumento é visualmente único, o diretor técnico da Tool, Bartek Drozdz, e eu os juntamos usando combinações de imagens PNG, CSS, SVG e elementos de tela.
Muitos dos instrumentos precisavam processar diferentes métodos de interação (como cliques, arrastos e dedilhados, todas as coisas que você esperaria fazer com um instrumento) mantendo a interface com o mecanismo de som do DinahMoe. Descobrimos que precisávamos de mais do que apenas mouseup e mousedown do JavaScript para oferecer uma experiência de jogo incrível.
Para lidar com todas essas variações, criamos um elemento "palco" que cobria a área de jogo, processando cliques, arrastos e dedilhados em todos os instrumentos.
A fase
O Stage é o controlador que usamos para configurar a função em um instrumento. como adicionar diferentes partes dos instrumentos com que o usuário vai interagir. Conforme adicionamos mais interações (como um "hit"), podemos adicioná-las ao protótipo do palco.
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
};
Como acessar o elemento e a posição do mouse
Nossa primeira tarefa é traduzir as coordenadas do mouse na janela do navegador para serem relativas ao elemento do palco. Para isso, precisamos levar em conta onde o palco está na página.
Como precisamos encontrar onde o elemento está em relação a toda a janela, não apenas o elemento pai, é um pouco mais complicado do que apenas observar os elementos offsetTop e offsetLeft. A opção mais fácil é usar getBoundingClientRect, que fornece a posição relativa à janela, assim como os eventos do mouse, e tem suporte em navegadores mais recentes.
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};
}
};
Se o getBoundingClientRect não existir, teremos uma função simples que vai apenas somar os deslocamentos, movendo a cadeia dos elementos pais até chegar ao corpo. Em seguida, subtraímos a distância percorrida pela janela para conseguir a posição relativa a ela. Se você estiver usando o jQuery, a função offset() vai lidar muito bem com a complexidade de descobrir a localização em várias plataformas, mas ainda será necessário subtrair a quantidade rolada.
Sempre que a página é rolada ou redimensionada, é possível que a posição do elemento tenha mudado. Podemos detectar esses eventos e verificar a posição novamente. Esses eventos são acionados muitas vezes em uma rolagem ou redimensionamento típico. Portanto, em um aplicativo real, é melhor limitar a frequência com que você verifica a posição. Há muitas maneiras de fazer isso, mas o HTML5 Rocks tem um artigo sobre como desativar eventos de rolagem usando requestAnimationFrame, que vai funcionar bem aqui.
Antes de processarmos qualquer detecção de acerto, este primeiro exemplo vai apenas gerar os valores x e y relativos sempre que o mouse for movido na área do palco.
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 começar a observar o movimento do mouse, vamos criar um novo objeto de palco e transmitir o ID do div que queremos usar como palco.
//-- Create a new Stage object, for a div with id of "stage"
var stage = new Stage("stage");
Detecção de acerto simples
No JAM com o Chrome, nem todas as interfaces de instrumentos são complexas. Os pads da nossa bateria eletrônica são retângulos simples, o que facilita a detecção de um clique dentro dos limites deles.
Começando com retângulos, vamos configurar alguns tipos básicos de formas. Cada objeto de forma precisa conhecer os limites e ter a capacidade de verificar se um ponto está dentro dele.
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 novo tipo de forma que adicionarmos vai precisar de uma função no objeto de palco para ser registrado como uma zona de acerto.
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;
};
Em eventos de mouse, cada instância de forma vai verificar se o x e o y do mouse transmitidos são um acerto e retornar verdadeiro ou falso.
Também podemos adicionar uma classe "ativa" ao elemento de palco que vai mudar o cursor do mouse para um ponteiro ao passar o cursor sobre o quadrado.
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);
Mais formas
À medida que as formas ficam mais complicadas, a matemática para descobrir se um ponto está dentro delas fica mais complexa. No entanto, essas equações são bem estabelecidas e documentadas em muitos lugares on-line. Alguns dos melhores exemplos de JavaScript que vi são da biblioteca de geometria de Kevin Lindsey.
Felizmente, ao criar o JAM com o Chrome, nunca precisamos ir além de círculos e retângulos, dependendo de combinações de formas e camadas para lidar com qualquer complexidade extra.
Círculos
Para verificar se um ponto está dentro de um tambor circular, precisamos criar uma forma de base circular. Embora seja bastante semelhante ao retângulo, ele tem métodos próprios para determinar limites e verificar se o ponto está dentro do 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;
};
Em vez de mudar a cor, adicionar a classe de acerto vai acionar uma animação CSS3. O tamanho do plano de fundo é uma boa maneira de dimensionar rapidamente a imagem do tambor, sem afetar a posição dele. Você vai precisar adicionar outros prefixos de navegador para que isso funcione (-moz, -o e -ms) e talvez queira adicionar uma versão sem prefixo também.
#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
Nossa função GuitarString vai receber um ID de tela e um objeto Rect e desenhar uma linha no centro desse retâ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;
}
Quando quisermos que ele vibre, vamos chamar nossa função de strum para colocar a corda em movimento. Cada frame renderizado reduz ligeiramente a força com que ele foi tocado e aumenta um contador que faz a corda oscilar para frente e para trá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;
};
Interseções e dedilhado
Nossa área de acerto para a string será apenas uma caixa. Clicar nessa caixa aciona a animação da string. Mas quem quer clicar em um violão?
Para adicionar o dedilhado, precisamos verificar a interseção da caixa de cordas e a linha que o mouse do usuário está percorrendo.
Para conseguir uma distância suficiente entre a posição anterior e a atual do mouse, precisamos diminuir a taxa de recebimento dos eventos de movimento do mouse. Neste exemplo, vamos definir uma flag para ignorar eventos mousemove por 50 milissegundos.
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);
};
Em seguida, vamos precisar usar um código de interseção escrito por Kevin Lindsey para verificar se a linha de movimento do mouse cruza o meio do retâ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 fim, vamos adicionar uma nova função para criar um instrumento de corda. Ele vai criar o novo estágio, configurar um número de strings e receber o contexto da tela em que o objeto será desenhado.
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;
}
Em seguida, vamos posicionar as áreas de acerto das strings e adicioná-las ao elemento de palco.
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 fim, a função de renderização do StringInstrument vai percorrer todas as strings e chamar os métodos de renderização delas. Ela é executada o tempo todo, tão rápido quanto a requestAnimationFrame achar necessário. Saiba mais sobre requestAnimationFrame no artigo requestAnimationFrame para animação inteligente de Paul Irish.
Em um aplicativo real, talvez você queira definir uma flag quando nenhuma animação estiver ocorrendo para parar de desenhar um novo frame da tela.
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);
}
};
Conclusão
Ter um elemento de palco comum para processar todas as interações não é sem desvantagens. É mais complexo computacionalmente, e os eventos do ponteiro do cursor são limitados sem a adição de um código extra para mudá-los. No entanto, para o JAM com o Chrome, os benefícios de abstrair eventos do mouse dos elementos individuais funcionaram muito bem. Isso nos permitiu experimentar mais o design da interface, alternar entre métodos de animação de elementos, usar SVG para substituir imagens de formas básicas, desativar facilmente áreas de acerto e muito mais.
Para ver as baterias e as guitarras em ação, inicie sua própria JAM e selecione Baterias padrão ou Guitarra elétrica clássica limpa.