事例紹介 - Chrome での JAM

UI を魅力的にした方法

はじめに

JAM with Chrome は、Google が作成したウェブベースの音楽プロジェクトです。JAM with Chrome では、世界中のユーザーがバンドを結成し、ブラウザ内でリアルタイムで JAM できます。DinahMoeChrome の Web Audio API で可能な範囲を広げ、Tool of North America のチームは、コンピュータを楽器のようにストローミング、ドラム、演奏するためのインターフェースを作成しました。

イラストレーターの Rob Bailey は、Google Creative Lab のクリエイティブ ディレクションのもと、JAM で使用できる 19 種類の楽器それぞれに精巧なイラストを作成しました。これらの要望に基づいて、インタラクティブ ディレクター Ben Tricklebank と YouTube のツール デザインチームが、各楽器に適した使いやすいプロ仕様のインターフェースを作成しました。

フルジャム モンタージュ

楽器はそれぞれ視覚的に異なるため、Tool のテクニカル ディレクターである Bartek Drozdz と私は、PNG 画像、CSS、SVG、キャンバス要素を組み合わせて楽器をつなぎ合わせました。

多くの楽器では、DinahMoe のサウンドエンジンとのインターフェースを同じに保ちながら、さまざまな操作方法(クリック、ドラッグ、ストロームなど、楽器で行うすべての操作)を処理する必要がありました。美しいゲームプレイを実現するには、JavaScript の mouseup と mousedown 以上の機能が必要であることがわかりました。

こうしたさまざまなバリエーションに対応するため、プレイ可能な領域を覆う「ステージ」要素を作成しました。この要素は、さまざまな楽器のクリック、ドラッグ、ストロームを処理します。

The 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 要素を基準とする座標に変換することです。そのためには、ステージがページのどこにあるかを考慮する必要がありました。

親要素だけでなく、ウィンドウ全体に対して要素がどこにあるかを特定する必要があるため、要素の 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 の ID を渡します。

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

振動させるには、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 for smart animating」をご覧ください。

実際のアプリケーションでは、アニメーションが行われていないときにフラグを設定して、新しいキャンバス フレームの描画を停止できます。

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 要素を使用するには、欠点もあります。計算が複雑になり、カーソル ポインタ イベントは、変更するための追加コードを追加しないと制限されます。一方、Chrome を使用した JAM では、個々の要素からマウスイベントを抽象化できるというメリットが非常に効果的でした。これにより、インターフェース デザインをより詳細にテストしたり、要素のアニメーション化方法を切り替えたり、SVG を使用して基本的な図形の画像を置き換えたり、ヒット領域を簡単に無効にしたりできるようになりました。

ドラムとストリングスを実際に試すには、独自のJAM を開始し、[標準ドラム] または [クラシック クリーン エレクトリック ギター] を選択します。

Jam のロゴ