Studium przypadku – JAM w Chrome

Jak stworzyliśmy UI

Wstęp

JAM with Chrome to internetowy projekt muzyczny stworzony przez Google. JAM with Chrome pozwala ludziom z całego świata tworzyć zespoły i JAM w czasie rzeczywistym z poziomu przeglądarki. DinahMoe przesunęła granice tego, co było możliwe dzięki interfejsowi Web Audio API w Chrome. Nasz zespół z Tool of North America opracował interfejs do grania na komputerze, grania na bębnach i gier tak, jakby był to instrument muzyczny.

Zgodnie z kierunkiem kreatywnym Google Creative Lab ilustrator Rob Bailey stworzył skomplikowane ilustracje do każdego z 19 instrumentów dostępnych dla JAM. Opierając się na tych zadaniach, dyrektor ds. interaktywnego, Ben Tricklebank, i nasz zespół projektowy z narzędzia Tool stworzyli łatwy i profesjonalny interfejs dla każdego instrumentu.

Montaż full jam

Ponieważ każdy instrument jest wyjątkowy pod względem wizualnym, Bartek Drozdz, dyrektor techniczny firmy Tool, połączyłem je ze sobą, używając kombinacji obrazów PNG, CSS, SVG i Canvas.

Wiele instrumentów musiało obsługiwać różne metody interakcji (takie jak kliknięcia, przeciąganie czy struny – wszystkie czynności, jakie można by wykonać na instrumencie), nie zmieniając przy tym interfejsu z silnikiem dźwięku DinahMoe. Aby zapewnić komfortową rozgrywkę, potrzebujemy czegoś więcej niż tylko obsługi kursora myszy w górę i w dół w JavaScript.

Aby uwzględnić tę zmianę, stworzyliśmy element „Stage” obejmujący obszar gry, a także obsługę kliknięć, przeciągania i strumień w przypadku różnych instrumentów.

Scena

Stage to nasz kontroler, za pomocą którego konfigurujemy funkcje na instrumencie. Może to być na przykład dodawanie różnych części instrumentów, z którymi użytkownik będzie wchodzić w interakcje. W miarę dodawania kolejnych interakcji (np. „działania”), możemy je dodawać do prototypu Etapu.

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

Określanie pozycji i położenia myszy

Pierwsze zadanie polega na przetłumaczeniu współrzędnych myszy w oknie przeglądarki w odniesieniu do elementu Stage. Musieliśmy więc wziąć pod uwagę umiejscowienie naszego etapu na stronie.

Musimy ustalić położenie elementu względem całego okna, a nie tylko jego elementu nadrzędnego, dlatego czynność ta jest nieco bardziej skomplikowana niż przyglądanie się tylko elementom shiftTop i offLeft. Najłatwiejszym sposobem jest użycie metody getBoundingClientRect, która określa pozycję okna (tak jak w przypadku zdarzeń myszy), która jest również 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 getBoundingClientRect nie istnieje, mamy prostą funkcję, która dodaje jedynie przesunięcia, przenosząc w górę łańcuch elementów nadrzędnych, aż dotrze do treści. Następnie odejmujemy zakres przewinięcia okna, aby uzyskać jego położenie względem okna. Jeśli korzystasz z biblioteki jQuery, funkcja shift() dobrze radzi sobie ze złożonością określania lokalizacji na różnych platformach, ale i tak trzeba odjąć liczbę przewiniętych elementów.

Po każdym przewinięciu strony lub zmianie rozmiaru strony możliwe jest, że pozycja elementu zmieniła się. Możemy nasłuchiwać tych zdarzeń i ponownie sprawdzić pozycję. Te zdarzenia są wywoływane wiele razy podczas typowego przewijania lub zmiany rozmiaru, więc w rzeczywistej aplikacji najlepiej jest ograniczyć częstotliwość ponownego sprawdzania pozycji. Można to zrobić na wiele sposobów, ale w ofercie HTML5 Rocks znajdziesz artykuł o odbijaniu zdarzeń przewijania za pomocą metody requestAnimationFrame.

Zanim zajmiemy się wykrywaniem trafień, w tym pierwszym przykładzie przy każdym ustawieniu kursora myszy w obszarze Stage zwracane są wartości x i y.

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 myszy, tworzymy nowy obiekt Stage i przekazujemy do niego identyfikator elementu 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");

Proste wykrywanie trafień

W JAM z Chrome nie wszystkie interfejsy instrumentu są złożone. Są to proste prostokąty, dzięki czemu łatwo jest wykryć, czy kliknięcie mieści się w ich granicach.

Automat perkusyjny

Zaczynając od prostokątów, ustawimy kilka rodzajów kształtów podstawowych. Każdy obiekt kształtu musi znać swoje granice i mieć możliwość sprawdzenia, czy znajduje się w nim punkt.

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, która zarejestruje go jako strefę trafienia.

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żde wystąpienie kształtu będzie obsługiwać sprawdzanie, czy przekazane wartości x i y myszy są jego trafieniami, i zwrócą wartość prawda lub fałsz.

Do elementu sceny możemy też dodać klasę „active”, która zmieni kursor myszy w taki sposób, że będzie się pojawiać po najechaniu na niego kursorem.

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

W miarę jak kształty stają się coraz bardziej złożone, matematyka, aby sprawdzić, czy jakiś punkt znajduje się w nich, staje się coraz bardziej skomplikowana. Jednak te równania są już dobrze znane i bardzo szczegółowo udokumentowane w wielu miejscach w internecie. Niektóre z najlepszych przykładów JavaScriptu, jakie widziałem, pochodzą z biblioteki geometrii Kevina Lindseya.

Na szczęście przy tworzeniu JAM w Chrome nigdy nie musieliśmy wykroczyć poza okręgi i prostokąty, wykorzystując kombinacje kształtów i warstw, które radziły sobie z nadmierną złożonością.

Kształty bębnów

Kółka

Aby sprawdzić, czy punkt znajduje się w okrągłym bębnie, trzeba utworzyć okrągły kształt podstawy. Chociaż jest on podobny do prostokąta, ma własne metody wyznaczania granic i sprawdzania, czy punkt znajduje się wewnątrz okręgu.

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 działania uruchamia animację CSS3. Rozmiar tła to dobry sposób na szybkie skalowanie obrazu bębna bez wpływu na jego pozycję. Aby korzystać z tej funkcji, musisz dodać prefiksy innych 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%;}
}

Ciąg znaków

Funkcja GuitarString pobierze identyfikator canvas i obiekt Rect, a następnie narysuje 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;
}

Jeśli ma on wibrować, wywołujemy funkcję strum, aby nadać ciągowi ruch. Każda wyrenderowana klatka zmniejszy siłę uderzenia i zwiększy licznik, który będzie powodować drgania struny.

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

Skrzyżowania i instrumenty kosmiczne

Obszarem działania, w którym struny będzie struny, znów będzie pudło. Kliknięcie go powinno aktywować animację ciągu znaków. Ale kto by chciał kliknąć gitarę?

Aby dodać podział, należy zaznaczyć pole przecięcia tekstu z linią, którą porusza mysz użytkownika.

Aby uzyskać odpowiednią odległość między poprzednią a bieżącą pozycją myszy, musimy spowolnić tempo, z jakim są rejestrowane zdarzenia ruchu myszy. W tym przykładzie ustawimy po prostu flagę ignorowania zdarzeń ruchu kursora myszy przez 50 milisekund.

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 polegać na kodzie przecięcia napisanym przez Kevina Lindseya, aby sprawdzić, czy linia ruchu myszy przecina środek 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ę tworzącą instrument strunowy. Spowoduje to utworzenie nowego etapu, ustawienie kilku ciągów znaków i pozyskanie kontekstu obszaru roboczego, który będzie rysowany.

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 działań struny i dodamy je do elementu 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);
  }
};

Na koniec funkcja renderowania StringInstrument dokona pętli przez wszystkie ciągi i wywoła ich metody renderowania. Działa przez cały czas, szybko, gdy element requestAnimationFrame zostanie uznany za odpowiedni. Więcej informacji o requestAnimationFrame znajdziesz w artykule Paula Irlandii o requestAnimationFrame for smart animating.

W rzeczywistej aplikacji możesz ustawić flagę, gdy żadna animacja nie jest wyświetlana, aby zatrzymać rysowanie nowej ramki canvas.

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

Wspólny element etapu, który obsługuje wszystkie nasze interakcje, musi też mieć swoje wady. Jest to bardziej złożone, a zdarzenia dotyczące wskaźnika kursora są ograniczone i można je zmieniać bez dodatkowego kodu. Jednak w przypadku JAM z Chrome korzyści płynące z odróżniania zdarzeń myszy od poszczególnych elementów sprawdzały się bardzo dobrze. Dzięki temu mogliśmy więcej eksperymentować z projektem interfejsu, przełączać się między metodami animowania elementów, używać SVG do zastępowania obrazów podstawowych kształtów, łatwego wyłączania obszarów działań itp.

Aby zobaczyć, jak działają perkusje i stingi, załóż własne urządzenie JAM i wybierz perkusję standardową lub klasyczną gitarę elektryczną.

Logo Jamu