Case study - Fai una JAM session con Chrome

Come abbiamo reso la UI più efficace

Fred Chasen
Fred Chasen

Introduzione

JAM con 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 JAM in tempo reale nel browser. DinahMoe ha superato i confini di ciò che era possibile ottenere con l'API Web Audio di Chrome, il nostro team di Tool of North America ha creato l'interfaccia per suonare strumming, batteria e suonare il tuo computer come se fosse uno strumento musicale.

Con la direzione creativa di Google Creative Lab, l'illustratore Rob Bailey ha creato complesse illustrazioni per ognuno dei 19 strumenti disponibili per JAM. In collaborazione con questi strumenti, il direttore interattivo Ben Tricklebank e il nostro team di progettazione di Tool hanno creato un'interfaccia semplice e professionale per ogni strumento.

Montaggio completo

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

Molti degli strumenti dovevano gestire diversi metodi di interazione (come clic, drag e strum, tutte le cose che ci si aspetterebbe di fare con uno strumento), mantenendo allo stesso tempo l'interfaccia con il motore sonoro di DinahMoe. Abbiamo scoperto che ci servivano molto di più del semplice mouseup e mousedown di JavaScript per poter offrire una fantastica esperienza di gioco.

Per gestire tutte queste variazioni, abbiamo creato un elemento "Stage" che copriva l'area riproducibile, gestendo i clic, i trascina e gli strumenti musicali.

Palcoscenico

Lo Stage è il nostro controller che utilizziamo per configurare la funzione di uno strumento. Ad esempio, puoi aggiungere diverse parti degli strumenti con cui l'utente interagirà. Man mano che aggiungiamo altre interazioni (come 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 l'elemento e la posizione del mouse

La prima attività consiste nel tradurre le coordinate del mouse nella finestra del browser in modo che siano relative all'elemento Stage. Per farlo, dovevamo prendere in considerazione il punto in cui si trova la pagina.

Poiché dobbiamo trovare la posizione dell'elemento rispetto all'intera finestra, non solo al suo elemento principale, è leggermente più complicato che esaminare semplicemente gli 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 funzione semplice che somma gli offset, spostandosi lungo la catena degli elementi principali dell'elemento fino a raggiungere il corpo dell'elemento. Quindi sottraiamo quanto è stata fatta scorrere la finestra per ottenere la posizione rispetto alla finestra. Se utilizzi jQuery, la funzione offset() è molto utile per gestire la complessità del rilevamento della posizione tra piattaforme diverse, ma devi comunque sottrarre la quantità di dati scorretti.

Ogni volta che la pagina viene fatta scorrere o ridimensionata, è possibile che la posizione dell'elemento cambi. Possiamo ascoltare questi eventi e controllare di nuovo la posizione. Questi eventi vengono attivati molte volte su un tipico scorrimento o ridimensionamento, quindi in un'applicazione reale probabilmente è meglio limitare la frequenza con cui ricontrollare la posizione. Ci sono molti modi per farlo, ma HTML5 Rocks ha un articolo per debouncing degli eventi di scorrimento utilizzando requestAnimationFrame, che qui funzionerà bene.

Prima di gestire il rilevamento degli hit, questo primo esempio restituirà i valori x e y relativi ogni volta che il mouse viene spostato nell'area Stage.

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 osservare il movimento del mouse, creiamo un nuovo oggetto Stage e lo passiamo all'ID del div da utilizzare come Stage.

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

Rilevamento semplice degli hit

In JAM con Chrome non tutte le interfacce degli strumenti sono complesse. I nostri pad per drum machine sono semplici rettangoli, che consentono di rilevare facilmente se un clic rientra nei loro limiti.

Drum machine

Iniziando dai rettangoli, imposteremo alcuni tipi di base di forme. 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 aggiunto avrà bisogno di una funzione all'interno dell'oggetto Stage per registrarlo come zona 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;
};

Per gli eventi del mouse, ogni istanza di forma gestirà il controllo se i valori x e y del mouse passati sono un hit e restituiscono true o false.

Possiamo anche aggiungere una classe "attiva" all'elemento dello stage che trasformi il cursore del mouse in un puntatore quando passi il mouse sul 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ù complesse, la matematica per capire se un punto si trova al loro interno diventa più complessa. Tuttavia, queste equazioni sono ben consolidate e documentate in grande dettaglio in molti luoghi online. Alcuni dei migliori esempi di JavaScript che ho visto sono tratti dalla libreria di geometria di Kevin Lindsey.

Fortunatamente, nella creazione di JAM con Chrome non abbiamo mai dovuto andare oltre i cerchi e i rettangoli, affidandoci a combinazioni di forme e alla sovrapposizione per gestire qualsiasi complessità aggiuntiva.

Forme di tamburi

Cerchi

Per controllare se un punto si trova all'interno di un tamburo circolare, dobbiamo creare una forma di base circolare. Sebbene sia molto simile al rettangolo, avrà metodi diversi per determinare i limiti e controllare 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;
};

Invece di modificare il colore, l'aggiunta della classe di hit attiva un'animazione CSS3. Le dimensioni dello sfondo sono un modo utile per ridimensionare rapidamente l'immagine del tamburo, senza modificarne la posizione. Per eseguire questa operazione, dovrai aggiungere i prefissi di altri browser (-moz, -o e -ms) e potresti voler aggiungere anche 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 traccia 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 nostra funzione strum per impostare la corda in movimento. Ogni fotogramma che eseguiamo il rendering ridurrà la forza su cui è stato strisciato 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 strimpellando

L'area sensibile della stringa sarà di nuovo una scatola. Se fai clic all'interno della casella dovrebbe essere attivata l'animazione della stringa. Ma chi vuole fare clic su una chitarra?

Per aggiungere lo strumming, dobbiamo selezionare l'intersezione della casella delle stringhe e la linea su cui si sposta il mouse dell'utente.

Per ottenere una distanza sufficiente tra la posizione precedente e quella corrente del mouse, dovremo rallentare la velocità con cui si ottengono gli eventi di spostamento del mouse. Per questo esempio, imposteremo semplicemente un flag per ignorare gli eventi di 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);
};

Ora dovremo basarci sul codice di intersezione scritto da Kevin Lindsey per vedere se la linea di movimento del mouse si interseca al centro del 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 corda. Verrà creato un nuovo stadio, verranno configurate una serie di stringhe e verrà specificato il contesto del canvas su cui verranno disegnate.

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

Successivamente posizioneremo le aree interessate 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 esegue un loop di tutte le nostre stringhe e chiama i relativi metodi di rendering. Viene eseguito sempre, alla velocità di requestAnimationFrame. Puoi trovare ulteriori informazioni su requestAnimationFrame nell'articolo di Paul Ireland requestAnimationFrame per l'animazione intelligente.

In un'applicazione reale, potresti voler impostare un flag quando non è in corso alcuna animazione per interrompere il disegno di una nuova cornice.

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 Fase comune per gestire tutte le nostre interazioni non è privo di svantaggi. È più complessa dal punto di vista del calcolo e gli eventi di puntatore del cursore sono limitati senza l'aggiunta di codice aggiuntivo per modificarli. Tuttavia, per JAM con Chrome, il vantaggio di poter astrarre gli eventi del mouse lontano dai singoli elementi ha funzionato molto bene. Ci consente di sperimentare ulteriormente il design dell'interfaccia, passare da un metodo all'altro di animazione degli elementi, utilizzare SVG per sostituire immagini di forme di base, disattivare facilmente le aree interessate e altro ancora.

Per vedere le batterie e le punture in azione, avvia la tua JAM e seleziona la Batteria Standard o la Chitarra elettrica classica Clean.

Logo Jam