Studium przypadku – JAM w Chrome

Jak stworzyliśmy świetny interfejs

Wprowadzenie

JAM with Chrome to internetowy projekt muzyczny stworzony przez Google. JAM with Chrome pozwala użytkownikom z całego świata tworzyć zespoły i grać w czasie rzeczywistym w przeglądarce. DinahMoe przesunął granice tego, co jest możliwe dzięki interfejsowi Web Audio API w Chrome. Nasz zespół w Tool of North America stworzył interfejs do gry na komputerze jak na instrumencie muzycznym.

Pod kierunkiem Google Creative Lab ilustrator Rob Bailey stworzył szczegółowe ilustracje wszystkich 19 instrumentów dostępnych w JAM. Na ich podstawie dyrektor ds. interakcji Ben Tricklebank i nasz zespół projektowy w Tool stworzyli prosty i profesjonalny interfejs dla każdego instrumentu.

Pełna kompilacja jam

Każdy instrument jest wizualnie niepowtarzalny, więc dyrektor techniczny Toola, Bartek Drozdz, i ja połączyliśmy je ze sobą, używając kombinacji obrazów PNG, CSS, SVG i elementów Canvas.

Wiele instrumentów musiało obsługiwać różne metody interakcji (takie jak kliknięcia, przeciąganie i pociągnięcia smyczka – wszystkie czynności, które można wykonywać za pomocą instrumentu), zachowując przy tym interfejs silnika dźwiękowego DinahMoe. Okazało się, że aby zapewnić użytkownikom wspaniałe wrażenia z gry, nie wystarczyło tylko użycie JavaScriptu do obsługi zdarzeń mouseup i mousedown.

Aby uwzględnić te wszystkie różnice, stworzyliśmy element „Scena”, który pokrywa obszar gry, obsługując kliknięcia, przeciąganie i pociągnięcia palcem na wszystkich instrumentach.

Etap

Stage to nasz kontroler, który służy do konfigurowania funkcji w poszczególnych instrumentach. Na przykład dodanie różnych części instrumentów, z którymi użytkownik będzie wchodzić w interakcję. W miarę dodawania kolejnych interakcji (np. „kliknięcia”) możesz je dodawać do prototypu sceny.

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

Pobieranie pozycji elementu i kursora

Naszym pierwszym zadaniem jest przekształcenie współrzędnych myszy w oknie przeglądarki tak, aby były względne do elementu Stage. Aby to zrobić, musieliśmy wziąć pod uwagę, gdzie na stronie znajduje się nasz Stage.

Ponieważ musimy określić położenie elementu względem całego okna, a nie tylko jego elementu nadrzędnego, sprawa jest nieco bardziej skomplikowana niż tylko sprawdzenie offsetu góry i offsetu lewej strony. Najprostszą opcją jest użycie metody getBoundingClientRect, która zwraca pozycję w stosunku do okna, podobnie jak zdarzenia myszy, i jest dobrze obsługiwana w nowszych przeglądarkach.

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

Jeśli funkcja getBoundingClientRect nie istnieje, mamy prostą funkcję, która po prostu doda przesunięcia, przechodząc w górę łańcucha elementów nadrzędnych, aż do elementu body. Następnie odejmujemy odległość przewinięcia okna, aby uzyskać pozycję względem okna. Jeśli używasz jQuery, funkcja offset() świetnie radzi sobie z złożonością określania lokalizacji na różnych platformach, ale nadal musisz odjąć wartość przewinięcia.

Gdy przewijasz stronę lub zmieniasz jej rozmiar, pozycja elementu może się zmienić. Możemy nasłuchiwać tych zdarzeń i ponownie sprawdzać pozycję. Te zdarzenia są wywoływane wiele razy podczas typowego przewijania lub zmiany rozmiaru, dlatego w rzeczywistej aplikacji najlepiej jest ograniczyć częstotliwość sprawdzania pozycji. Można to zrobić na wiele sposobów, ale na stronie HTML5 Rocks znajdziesz artykuł o debounce’owaniu zdarzeń przewijania za pomocą metody requestAnimationFrame, która sprawdzi się w tym przypadku.

Zanim zajmiemy się wykrywaniem uderzeń, w tym pierwszym przykładzie będziemy po prostu wyświetlać względne współrzędne x i y, gdy tylko przesuniesz mysz w obszarze sceny.

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

Aby zacząć obserwować ruch kursora, utworzymy nowy obiekt Stage i przekażemy mu identyfikator div, którego chcemy użyć jako sceny.

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

Prosty sposób wykrywania trafień

W JAM z Chrome nie wszystkie interfejsy instrumentów są skomplikowane. Pady bębna są prostymi prostokątami, co ułatwia wykrycie, czy kliknięcie znajduje się w ich obrębie.

automat perkusyjny,

Zaczynamy od prostokątów, a potem skonfigurujemy podstawowe typy kształtów. Każdy obiekt kształtu musi znać swoje granice i mieć możliwość sprawdzenia, czy punkt znajduje się w jego obrębie.

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

Każdy nowy typ kształtu, który dodamy, będzie wymagał funkcji w obiekcie Stage, aby zarejestrować go jako strefę dotyku.

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

W przypadku zdarzeń myszy każda instancja kształtu sprawdza, czy przekazane współrzędne x i y myszy są dla niej odpowiednie, i zwraca wartość true lub false.

Możemy też dodać do elementu sceny klasę „active”, która spowoduje, że kursor myszy zmieni się w wskaźnik, gdy najedziemy na kwadrat.

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

Więcej kształtów

Im bardziej skomplikowane są kształty, tym bardziej skomplikowane są obliczenia służące do określania, czy punkt znajduje się w ich wnętrzu. Jednak te równania są dobrze znane i szczegółowo opisane w wielu miejscach w internecie. Niektóre z najlepszych przykładów kodu JavaScript, jakie widziałem, pochodzą z biblioteki geometrycznej Kevina Lindseya.

Na szczęście podczas tworzenia JAM w Chrome nie musieliśmy nigdy używać innych kształtów niż koła i prostokąty. Do tworzenia bardziej złożonych kształtów wystarczały nam kombinacje tych kształtów i warstw.

Kształty bębna

Kręgi

Aby sprawdzić, czy punkt znajduje się w okrągłym bębnie, musimy utworzyć okrągły kształt bazowy. Chociaż jest on dość podobny do prostokąta, ma własne metody określania granic i sprawdzania, czy punkt znajduje się wewnątrz koła.

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

Zamiast zmieniać kolor, dodanie klasy hit spowoduje uruchomienie animacji CSS3. Rozmiar tła pozwala szybko skalować obraz bębna bez wpływu na jego położenie. Aby to działało, musisz dodać inne prefiksy przeglądarek (-moz, -o i -ms). Możesz też dodać wersję bez prefiksu.

#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

Funkcja GuitarString przyjmuje identyfikator kanwy i obiekt Rect, a następnie rysuje linię przez środek tego prostokąta.

function GuitarString(rect) {
  this.x = rect.x;
  this.y = rect.y + rect.height / 2;
  this.width = rect.width;
  this._strumForce = 0;
  this.a = 0;
}

Gdy chcemy, aby wibrował, wywołujemy funkcję strum, aby ustawić strunę w ruchu. W każdym renderowanym ujęciu siła będzie nieco mniejsza, a licznik, który powoduje oscylacje struny, będzie się zwiększał.

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

Intersections and Strumming

Obszar docelowy dla tego ciągu będzie znowu pudełkiem. Kliknięcie tego pola powinno wywołać animację ciągu znaków. Ale kto chce klikać gitarę?

Aby dodać strumienienie, musimy sprawdzić przecięcie pola strun i linii, po której porusza się kursor myszy.

Aby uzyskać wystarczającą odległość między poprzednią a bieżącą pozycją myszy, musimy spowolnić tempo, z jakim otrzymujemy zdarzenia dotyczące jej ruchu. W tym przykładzie po prostu ustawimy flagę, aby ignorować zdarzenia mousemove przez 50 ms.

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

Następnie będziemy musieli polegać na kodzie kolizji napisanym przez Kevina Lindseya, aby sprawdzić, czy linia ruchu myszy przecina środek naszego prostokąta.

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

Na koniec dodamy nową funkcję do tworzenia instrumentów smyczkowych. Spowoduje to utworzenie nowego etapu, skonfigurowanie kilku ciągów znaków i uzyskanie kontekstu płótna, na którym będzie narysowany obiekt.

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

Następnie ustawimy obszary dopasowania strun, a potem dodamy je do elementu Scena.

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

Na koniec funkcja renderowania StringInstrument będzie przeszukiwać wszystkie nasze ciągi znaków i wywoływać ich metody renderowania. Jest ona wykonywana cały czas z taką szybkością, jaką uzna za odpowiednią metoda requestAnimationFrame. Więcej informacji o metodzie requestAnimationFrame znajdziesz w artykule Paula Irisha requestAnimationFrame – inteligentna animacja.

W rzeczywistej aplikacji możesz ustawić flagę, gdy nie występuje animacja, aby zatrzymać rysowanie nowej ramki kanwy.

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

Podsumowanie

Użycie wspólnego elementu Stage do obsługi wszystkich interakcji ma swoje wady. Jest to bardziej złożone pod względem obliczeniowym, a zdarzenia związane z wskaźnikiem kursora są ograniczone bez dodawania dodatkowego kodu, który je zmienia. W przypadku JAM w Chrome jednak korzyści płynące z możliwości abstrakcyjnego odseparowania zdarzeń myszy od poszczególnych elementów okazały się bardzo przydatne. Dzięki temu mogliśmy więcej eksperymentować z projektowaniem interfejsu, przełączać się między metodami animowania elementów, zastępować obrazy podstawowych kształtów plikami SVG, łatwo wyłączać obszary docelowe i wykonywać inne czynności.

Aby zobaczyć bębny i motywy, utwórz własną JAM i wybierz Standardowe bębny lub Klasyczna czysta gitara elektryczna.

Logo Jam