個案研究 - 使用 Chrome 的 JAM

如何讓 UI 更出色

Fred Chasen
Fred Chasen

簡介

JAM with Chrome 是 Google 推出的網路音樂專案,透過 Chrome 進行 JAM 時,來自世界各地的使用者可以組成樂團,並在瀏覽器中即時進行 JAM。DinahMoe 團隊突破了 Chrome 的 Web Audio API 的限制,Tool of North America 團隊打造了這個介面,讓使用者可以彈撥、敲打和演奏電腦,就像是樂器一樣。

在 Google Creative Lab 的創意指導下,插畫家 Rob Bailey 為 JAM 可使用的 19 種樂器,各自繪製精緻的插圖。根據這些資訊,互動式總監 Ben Tricklebank 和我們的工具設計團隊為每個樂器建立了簡單專業的介面。

完整的 Jam 蒙太奇

由於每個樂器的視覺效果都各不相同,因此 Tool 的技術總監 Bartek Drozdz 和我使用 PNG 圖片、CSS、SVG 和 Canvas 元素的組合,將這些樂器拼接在一起。

許多樂器都必須處理不同的互動方式 (例如點擊、拖曳和撥弦 - 所有您預期樂器會執行的操作),同時保持與 DinahMoe 音效引擎的介面相同。我們發現,除了 JavaScript 的 mouseup 和 mousedown 之外,還需要其他元素才能提供絕佳的遊戲體驗。

為了處理所有這些變化,我們建立了涵蓋可播放區域的「舞台」元素,處理所有不同樂器的點擊、拖曳和撥弦動作。

階段

Stage 是我們用來在測試工具中設定函式的控制器。例如新增使用者將會互動的不同樂器部分。我們可以新增更多互動 (例如「命中」),並將這些互動加入 Stage 的原型設計。

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

取得元素和滑鼠位置

我們的第一項工作是將瀏覽器視窗中的滑鼠座標轉譯為相對於「Stage」元素的座標。為此,我們需要考量頁面中的 Stage 位置。

我們需要找出元素相對於整個視窗的位置,而非僅是相對於父項元素的位置,因此比起只查看元素的 offsetTop 和 offsetLeft,這項作業稍微複雜一些。最簡單的做法是使用 getBoundingClientRect,這個方法會提供相對於視窗的位置,就像滑鼠事件一樣,而且新版瀏覽器也支援

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

如果 getBoundingClientRect 不存在,我們會使用簡單的函式來加總偏移量,向上移動元素父項鏈結,直到到達主體為止。接著,我們會減去捲動視窗的距離,以取得相對於視窗的位置。如果您使用 jQuery,offset() 函式可輕鬆處理跨平台位置的複雜性,但您仍需要減去捲動的數量。

只要捲動或調整網頁大小,元素的位置就可能會變更。我們可以監聽這些事件,並再次檢查位置。在一般捲動或調整大小作業中,這些事件會觸發多次,因此在實際應用程式中,最好限制重新檢查位置的頻率。這麼做的方法有很多,但 HTML5 Rocks 有一篇文章介紹如何使用 requestAnimationFrame debounce 捲動事件,這在本例中會很實用。

在處理任何觸發偵測之前,這個第一個範例會在滑鼠在「Stage」區域移動時,輸出相對的 x 和 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);
};

如要開始監控滑鼠動作,我們會建立新的 Stage 物件,並將要用於 Stage 的 div ID 傳遞給該物件。

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

簡易命中偵測

在 JAM with Chrome 中,並非所有檢測介面都很複雜。我們的鼓機按鍵只是簡單的矩形,因此很容易偵測點擊是否落在其邊界內。

鼓機

我們將從矩形開始,設定一些基本形狀類型。每個形狀物件都必須知道其邊界,並能夠檢查點是否位於其中。

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

我們新增的每個形狀類型都需要在 Stage 物件中加入函式,才能將其登錄為命中區域。

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

在滑鼠事件中,每個形狀例項都會處理檢查傳遞的滑鼠 x 和 y 是否為命中目標,並傳回 true 或 false。

我們也可以在舞台元素中加入「active」類別,讓滑鼠游標在經過方塊時變成指標。

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

其他形狀

形狀越複雜,用來判斷點是否位於形狀內的數學運算就會越複雜。不過,這些等式已建立且在許多線上資源中都有詳細記錄。我看過的最佳 JavaScript 範例之一,就是 Kevin Lindsey 的幾何學程式庫

很幸的是,在使用 Chrome 建構 JAM 時,我們從未超出圓形和矩形的範圍,而是透過形狀和圖層組合來處理任何額外的複雜性。

鼓形狀

圓形

如要檢查某個點是否位於圓形鼓內,我們需要建立圓形底座形狀。雖然圓形與矩形非常相似,但它會使用專屬方法來判斷邊界,並檢查點是否位於圓形內。

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

新增命中類別不會變更顏色,而是會觸發 CSS3 動畫。背景大小可讓我們快速調整鼓的圖片,且不會影響其位置。您需要加入其他瀏覽器的前置字串 (-moz、-o 和 -ms) 才能使用這些瀏覽器,也許還要加入不含前置字串的版本。

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

字串

GuitarString 函式會採用畫布 ID 和 Rect 物件,並在矩形中央畫一條線。

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

當我們想讓它震動時,我們會呼叫 strum 函式,將字串設為運動狀態。每個算繪影格都會稍微降低撥弦的力道,並增加計數器,讓弦線來回擺動。

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

交集和彈撥

字串的觸發區域又會變成方塊。按一下該方塊即可觸發字串動畫。不過,誰會想按吉他呢?

如要加入撥弦效果,我們需要檢查弦樂盒與使用者滑鼠移動路徑的交點。

為了讓滑鼠先前和目前的位置有足夠的距離,我們需要放慢取得滑鼠移動事件的速度。在本例中,我們只需設定標記,即可在 50 毫秒內忽略 mousemove 事件。

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

接下來,我們需要使用 Kevin Lindsey 編寫的部分交錯程式碼,查看滑鼠移動的線是否與矩形中間交錯。

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

最後,我們會新增一個函式來建立弦樂器。它會建立新的 Stage、設定多個字串,並取得繪製作業的 Canvas 內容。

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

接下來,我們會為字串設定觸發區,然後將其加入 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);
  }
};

最後,StringInstrument 的轉譯函式會循環處理所有字串,並呼叫其轉譯方法。這項函式會持續執行,並以 requestAnimationFrame 認為合適的速度執行。如要進一步瞭解 requestAnimationFrame,請參閱 Paul Irish 的文章「requestAnimationFrame 可用於智慧動畫」。

在實際應用程式中,您可能會在沒有動畫發生時設定標記,以便停止繪製新的畫布影格。

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

總結

使用通用的 Stage 元素來處理所有互動,並非沒有缺點。運算作業更複雜,而且游標指標事件受到限制,除非新增額外程式碼才能變更。不過,對於 JAM with Chrome 來說,能夠將滑鼠事件抽象化,不必與個別元素互動,這項做法非常實用。這讓我們可以進一步嘗試介面設計、切換元素動畫方法、使用 SVG 取代基本圖形的圖片,以及輕鬆停用觸發區等等。

如要瞭解鼓和弦樂器的實際運作情形,請建立自己的JAM,然後選取「標準鼓」或「經典清晰電吉他」

Jam 標誌