Estudo de caso: JAM com Chrome

Como tornamos a interface incrível

Introdução

O JAM with Chrome é um projeto musical baseado na Web criado pelo Google. O JAM com o Chrome permite que pessoas do mundo todo formem uma banda e o JAM em tempo real no navegador. A DinahMoe expandiu 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, bater e tocar o 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 no JAM. Com isso em mente, o diretor interativo Ben Tricklebank e nossa equipe de design da Tool criaram uma interface fácil e profissional para cada instrumento.

Montagem completa da jam

Como cada instrumento é visualmente único, o diretor técnico da Tool, Bartek Drozdz, e eu os agrupamos usando combinações de imagens PNG, CSS, SVG e elementos Canvas.

Muitos dos instrumentos tiveram que lidar com diferentes métodos de interação (como cliques, arrastos e toques - tudo o que se se esperaria fazer com um instrumento), mantendo a mesma interface com o mecanismo de som do DinahMoe. Descobrimos que precisávamos de mais do que apenas o mouseup e o mousedown do JavaScript para oferecer uma bela experiência de jogo.

Para lidar com toda essa variação, criamos um elemento "Stage" que cobriu a área reproduzível, lidando com cliques, arrastos e toques em todos os instrumentos.

O palco

O estágio é nosso controlador que usamos para configurar a função em um instrumento. Por exemplo, adicionar diferentes partes dos instrumentos com que o usuário vai interagir. À medida que adicionamos mais interações (como um “hit”), podemos adicioná-las ao protótipo do estágio.

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 definir o elemento e a posição do mouse

A primeira tarefa é converter as coordenadas do mouse na janela do navegador para que elas fiquem relacionadas ao nosso elemento Stage. Para fazer isso, precisávamos levar em conta onde nosso estágio está na página.

Como precisamos descobrir onde o elemento está em relação a toda a janela, e não apenas ao elemento pai, é um pouco mais complicado do que apenas analisar 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. Além disso, ele é compatível com 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 getBoundingClientRect não existir, temos uma função simples que apenas adicionará deslocamentos, movendo a cadeia dos pais do elemento até chegar ao corpo. Em seguida, subtraímos o quanto a janela foi rolada para chegar à posição em relação a ela. Se estiver usando jQuery, a função offset() faz um ótimo trabalho ao lidar com a complexidade de descobrir o local entre as plataformas, mas você ainda precisará subtrair o valor rolado.

Sempre que a página é rolada ou redimensionada, é possível que a posição do elemento tenha mudado. Podemos ouvir esses eventos e verificar a posição novamente. Esses eventos são disparados muitas vezes em uma rolagem ou redimensionamento comum. Portanto, em um aplicativo real, provavelmente é melhor limitar a frequência com que você verifica novamente a posição. Há muitas maneiras de fazer isso, mas o HTML5 Rocks tem um artigo para debater eventos de rolagem usando requestAnimationFrame, que funcionará bem aqui.

Antes de processarmos qualquer detecção de ocorrência, o primeiro exemplo emitirá apenas os valores x e y relativos sempre que o mouse for movido na área "Stage" (Estágio).

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, criamos um novo objeto Stage e passamos a ele o ID do div que queremos usar como o nosso Stage.

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

Detecção de hits simples

No JAM com o Chrome, nem todas as interfaces de instrumento são complexas. Nossos pads de bateria são retângulos simples, o que facilita a detecção de cliques.

Bateria

Começando com os retângulos, vamos configurar alguns tipos básicos de formas. Cada objeto de forma precisa conhecer seus limites e 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 precisará de uma função no objeto Stage para registrá-lo como uma 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;
};

Em eventos de mouse, cada instância de forma manipulará a verificação se as entradas x e y do mouse transmitidas representam uma ocorrência para ele e retornará verdadeiro ou falso.

Também podemos adicionar uma classe "active" ao elemento do estágio que fará com que o cursor do mouse se torne um ponteiro ao passar o mouse 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 se tornam mais complicadas, a matemática para descobrir se um ponto está dentro delas fica mais complexa. No entanto, essas equações estão bem estabelecidas e documentadas com muitos detalhes em muitos lugares online. 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 tivemos que ir além de círculos e retângulos, contando com combinações de formas e camadas para lidar com qualquer complexidade extra.

Formas de tambor

Círculos

Para verificar se um ponto está dentro de um tambor circular, precisamos criar uma forma com base em círculo. Embora seja bastante semelhante ao retângulo, ele tem seus próprios métodos para determinar os 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 alterar a cor, adicionar a classe de hit acionará uma animação CSS3. O tamanho do plano de fundo é uma boa maneira de dimensionar rapidamente a imagem da bateria, sem afetar a posição dela. Você vai precisar adicionar os prefixos de outros navegadores para esse trabalho com eles (-moz, -o e -ms) e também uma versão sem prefixo.

#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 usar 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 queremos que ela vibre, chamamos a função strum para colocar a corda em movimento. Cada frame renderizado reduz um pouco a força do toque e aumenta um contador que faz com que a string oscile 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 deformação

Nossa área de toque para a string vai ser apenas uma caixa novamente. Clicar nessa caixa deve acionar a animação da string. Mas quem quer tocar violão?

Para adicionar o deslizamento, precisamos verificar a interseção da caixa das strings e a linha que o mouse do usuário está movimentando.

Para obter uma distância suficiente entre a posição anterior e atual do mouse, precisaremos desacelerar a taxa em que os eventos de movimento do mouse são obtidos. Nesse exemplo, simplesmente definiremos uma sinalização para ignorar eventos de movimentação do mouse 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, precisaremos de um código de interseção que Kevin Lindsey escreveu para ver se a linha de movimento do mouse cruza o meio do nosso 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 string. Ele vai criar um novo cenário, configurar várias strings e entender o contexto do canvas em que o desenho 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 toque das strings e adicioná-las ao 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 fim, a função de renderização de StringInstrument vai fazer a repetição de todas as strings e chamar os métodos de renderização correspondentes. Ele é executado o tempo todo, rapidamente, conforme o requestAnimationFrame for adequado. Saiba mais sobre o requestAnimationFrame no artigo de PaulIrlandês, requestAnimationFrame for smart animating.

Em um aplicativo real, é possível definir um flag quando nenhuma animação estiver ocorrendo para parar de desenhar um novo frame de 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 comum de estágio para lidar com toda a nossa interação não acontece sem suas desvantagens. Em termos computacionais, ela é mais complexa, e os eventos de ponteiro do cursor são limitados, sem a adição de código extra para fazer mudanças. No entanto, para o JAM com o Chrome, os benefícios de poder abstrair eventos de mouse dos elementos individuais funcionaram muito bem. Isso nos permitiu experimentar mais com 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 toque e muito mais.

Para ver os tambores e picadas em ação, abra seu próprio JAM e selecione Standard Drums ou Classic Clean Electric Guitar.

Logotipo da Jam