個案研究 - 使用 Chrome 的 JAM

我們如何打造出真正的 UI

Fred Chasen
Fred Chasen

簡介

JAM 版 Chrome 是由 Google 建立的網路音樂專案。世界各地的使用者可透過 JAM Chrome 使用 JAM,透過瀏覽器即時組隊和 JAM。DinahMoe 突破了 Chrome Web Audio API 的無限可能,我們的 North America 工具團隊打造了打鼓、打鼓和演奏電腦的介面,就像演奏樂器一樣。

根據 Google 創意研究室的創作方向,插畫家 Rob Bailey 針對 JAM 支援的 19 種樂器分別製作精美插圖。經過這些開發後,互動式總監 Ben Tricklebank 和我們的工具設計團隊為每種樂器打造了簡單、專業的介面。

滿載即興拼貼

每種樂器的風格各不相同,因此工具的技術總監 Bartek Drozdz。我使用 PNG 圖片、CSS、SVG 和 Canvas 元素來混搭兩者。

許多樂器必須處理不同的互動方法 (例如點擊、拖曳和彈奏等),同時維持 DinahMoe 音響引擎的介面。我們發現,除了 JavaScript 的滑鼠上移和滑鼠下操作,更能提供美妙的遊戲體驗。

為處理上述所有變化版本,我們建立了涵蓋可播放區域的「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 元素。這麼做時,我們必須將各階段出現在該階段的情況納入考量。

我們需要找到元素與整個視窗的相對位置,而非只是父項元素,因此比起只查看元素 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 來取消捲動事件的文章,本文所述的方式也很有效。

在處理任何命中偵測前,只要滑鼠在舞台區中移動,第一個範例只會輸出相對的 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 編號。

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

簡易命中偵測

在搭配 Chrome 使用的 JAM 中,並非所有付款方式介面都很複雜。我們的鼓機墊是簡單的矩形,可以輕鬆偵測點擊是否落在邊界內。

鼓機

從矩形開始,我們將設定部分基本類型的形狀。每個形狀物件都需要知道界限,以及檢查物件是否在內的某個點。

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

希望設定震動時,我們會呼叫堆疊函式,以動態方式設定字串。我們算繪的每個影格都會稍微減少震撼的力量,並且增加一個會導致字串散發的計數器。

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 毫秒內忽略滑鼠移動事件。

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

最後,我們要新增一個函式來建立字串儀。該工具會建立新的階段、設定一些字串,並取得畫布的結構定義,以便繪製畫布。

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 的顯示效果。您可以參閱 Paul Ireland 的「requestAnimationFrame for smart animating」一文,進一步瞭解 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 元素放在一個常見的 Stage 元素,不必有缺點。這樣的運算方式較為複雜,且遊標指標事件有限,必須新增額外的程式碼來變更這些事件。然而,搭配 Chrome 使用 JAM,可從個別元素中擷取滑鼠事件,因此效果很好。我們可以針對介面設計進行更多實驗、切換動畫元素方法、使用 SVG 取代基本形狀的圖片、輕鬆停用命中區域等等。

如要查看鼓與點子的實際運作情形,請開啟 JAM,然後選取「Standard Drums」或「經典清潔電吉他」

Jam 標誌