Case study - Fai una JAM session con Chrome

Come abbiamo reso fantastica l'interfaccia utente

Fred Chasen
Fred Chasen

Introduzione

JAM with Chrome è un progetto musicale basato sul web creato da Google. JAM con Chrome consente a persone di tutto il mondo di formare una band e suonare in tempo reale all'interno del browser. DinahMoe ha spinto i confini di ciò che era possibile con l'API Web Audio di Chrome, il nostro team di Tool of North America ha creato l'interfaccia per strimpellare, suonare la batteria e suonare il computer come se fosse uno strumento musicale.

Con la direzione creativa di Google Creative Lab, l'illustratore Rob Bailey ha creato illustrazioni intricate per ciascuno dei 19 strumenti disponibili su JAM. Sulla base di queste informazioni, il direttore interattivo Ben Tricklebank e il nostro team di progettazione di Tool hanno creato un'interfaccia facile e professionale per ogni strumento.

Montaggio completo del jam

Poiché ogni strumento è visivamente unico, io e il direttore tecnico di Tool Bartek Drozdz li abbiamo uniti utilizzando combinazioni di immagini PNG, CSS, SVG ed elementi Canvas.

Molti degli strumenti dovevano gestire diversi metodi di interazione (come clic, trascinamenti e strumming, tutte le azioni che ci si aspetta di fare con uno strumento) mantenendo invariata l'interfaccia con il motore audio di DinahMoe. Per offrire un'esperienza di gioco ottimale, abbiamo scoperto che non bastavano i comandi mouseup e mousedown di JavaScript.

Per gestire tutte queste variazioni, abbiamo creato un elemento "Stage" che copriva l'area di gioco, gestendo clic, trascinamenti e strumming su tutti i diversi strumenti.

The Stage

Stage è il nostro controller che utilizziamo per configurare la funzione su uno strumento. Ad esempio, l'aggiunta di parti diverse degli strumenti con cui l'utente interagirà. Man mano che aggiungiamo altre interazioni (ad esempio un "hit"), possiamo aggiungerle al prototipo della fase.

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

Ottenere la posizione dell'elemento e del mouse

Il nostro primo compito è tradurre le coordinate del mouse nella finestra del browser in modo che siano relative all'elemento Stage. Per farlo, abbiamo dovuto tenere conto della posizione della nostra fase nella pagina.

Poiché dobbiamo trovare la posizione dell'elemento rispetto all'intera finestra, non solo rispetto all'elemento principale, è leggermente più complicato rispetto al semplice controllo degli elementi offsetTop e offsetLeft. L'opzione più semplice è utilizzare getBoundingClientRect, che fornisce la posizione relativa alla finestra, proprio come gli eventi del mouse, ed è ben supportata nei browser più recenti.

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 non esiste, abbiamo una semplice funzione che somma gli offset, risalendo la catena degli elementi principali fino a raggiungere il corpo. Poi sottraiamo la distanza percorsa con lo scorrimento della finestra per ottenere la posizione relativa alla finestra. Se utilizzi jQuery, la funzione offset() gestisce molto bene la complessità di determinare la posizione su più piattaforme, ma dovrai comunque sottrarre l'importo visualizzato.

Ogni volta che la pagina viene s scrolled o ridimensionata, è possibile che la posizione dell'elemento sia cambiata. Possiamo rilevare questi eventi e controllare di nuovo la posizione. Questi eventi vengono attivati molte volte in caso di scorrimento o ridimensionamento tipici, quindi in un'applicazione reale è probabilmente meglio limitare la frequenza con cui ricontrollare la posizione. Esistono molti modi per farlo, ma HTML5 Rocks ha un articolo per eliminare il ritardo degli eventi di scorrimento utilizzando requestAnimationFrame, che funzionerà bene in questo caso.

Prima di gestire il rilevamento dei hit, questo primo esempio mostrerà solo i valori x e y relativi ogni volta che il mouse viene spostato nell'area della scena.

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

Per iniziare a monitorare il movimento del mouse, creeremo un nuovo oggetto Stage e gli passeremo l'ID del div che vogliamo utilizzare come Stage.

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

Rilevamento semplice dei hit

In JAM con Chrome non tutte le interfacce degli strumenti sono complesse. I pad della nostra drum machine sono semplici rettangoli, quindi è facile rilevare se un clic rientra nei loro limiti.

Drum machine

Partendo dai rettangoli, imposteremo alcuni tipi di forme di base. Ogni oggetto forma deve conoscere i propri limiti e avere la possibilità di verificare se un punto si trova al suo interno.

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

Ogni nuovo tipo di forma che aggiungiamo avrà bisogno di una funzione all'interno dell'oggetto Stage per registrarlo come area di impatto.

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

Negli eventi del mouse, ogni istanza di forma gestirà il controllo se i valori x e y del mouse passati sono un hit per la forma e restituisce true o false.

Possiamo anche aggiungere una classe "active" all'elemento stage che trasformerà il cursore del mouse in un cursore quando passi il mouse sopra il quadrato.

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

Altre forme

Man mano che le forme diventano più complicate, la matematica per determinare se un punto si trova al loro interno diventa più complessa. Tuttavia, queste equazioni sono ben consolidate e documentate in modo molto dettagliato in molti siti online. Alcuni dei migliori esempi di JavaScript che ho visto provengono dalla libreria di geometria di Kevin Lindsey.

Fortunatamente, durante la creazione di JAM con Chrome non abbiamo mai dovuto andare oltre cerchi e rettangoli, basandoci su combinazioni di forme e livelli per gestire eventuali complessità aggiuntive.

Forme di tamburi

Cerchi

Per verificare se un punto si trova all'interno di un tamburo circolare, dobbiamo creare una forma di base circolare. Sebbene sia abbastanza simile al rettangolo, avrà i propri metodi per determinare i limiti e verificare se il punto si trova all'interno del cerchio.

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

Anziché cambiare il colore, l'aggiunta della classe hit attiverà un'animazione CSS3. Le dimensioni dello sfondo ci offrono un ottimo modo per ridimensionare rapidamente l'immagine del tamburo, senza influire sulla sua posizione. Dovrai aggiungere i prefissi di altri browser per utilizzarli (-moz, -o e -ms) e potresti anche aggiungere una versione senza prefisso.

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

Stringa

La nostra funzione GuitarString prende un ID canvas e un oggetto Rect e disegna una linea al centro del rettangolo.

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 vogliamo che vibri, chiamiamo la funzione strum per mettere in movimento la corda. Ogni fotogramma che rendiamo ridurrà leggermente la forza con cui è stata strimpellata la corda e aumenterà un contatore che farà oscillare la corda avanti e indietro.

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

Incroci e strumming

La nostra area di hit per la stringa sarà di nuovo una casella. Se fai clic all'interno della casella, l'animazione della stringa dovrebbe essere attivata. Ma chi vuole suonare una chitarra?

Per aggiungere lo strumming, dobbiamo controllare l'intersezione della casella delle corde e la linea lungo la quale si muove il mouse dell'utente.

Per ottenere una distanza sufficiente tra la posizione precedente e quella attuale del mouse, dobbiamo rallentare la frequenza con cui riceviamo gli eventi di movimento del mouse. Per questo esempio, imposteremo semplicemente un flag per ignorare gli eventi mousemove per 50 millisecondi.

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 questo punto dovremo fare affidamento su un codice di intersezione scritto da Kevin Lindsey per verificare se la linea di movimento del mouse interseca il centro del nostro rettangolo.

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

Infine, aggiungeremo una nuova funzione per creare uno strumento a corde. Creerà la nuova fase, imposterà una serie di stringhe e otterrà il contesto della tela su cui verrà disegnato.

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

Poi posizioneremo le aree di impatto delle stringhe e le aggiungeremo all'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);
  }
};

Infine, la funzione di rendering di StringInstrument eseguirà un ciclo per tutte le stringhe e chiamerà i relativi metodi di rendering. Viene eseguito sempre, alla velocità che ritiene opportuna requestAnimationFrame. Per saperne di più su requestAnimationFrame, consulta l'articolo di Paul Irish requestAnimationFrame per animazioni intelligenti.

In un'applicazione reale, potresti voler impostare un flag quando non è in corso alcuna animazione per interrompere il disegno di un nuovo frame della 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);
  }
};

Conclusione

Avere un elemento Stage comune per gestire tutte le nostre interazioni non è privo di inconvenienti. È più complesso dal punto di vista computazionale e gli eventi del cursore sono limitati senza l'aggiunta di codice aggiuntivo per modificarli. Tuttavia, per JAM con Chrome, i vantaggi di poter astrarre gli eventi del mouse dai singoli elementi hanno funzionato molto bene. Ci consente di fare più esperimenti con il design dell'interfaccia, passare da un metodo all'altro per l'animazione degli elementi, utilizzare SVG per sostituire le immagini di forme di base, disattivare facilmente le aree di impatto e altro ancora.

Per vedere i tamburi e gli arpeggi in azione, crea la tua JAM e seleziona Batteria standard o Chitarra elettrica pulita classica.

Logo di Jam