案例研究 - JAM with Chrome

我们如何让界面设计出色

Fred Chasen
Fred Chasen

简介

JAM with Chrome 是由 Google 打造的基于网络的音乐项目。JAM with Chrome 可让世界各地的用户在浏览器中实时组成乐队和 JAM。DinahMoe 推动了 Chrome 的 Web Audio API 的极限,Tool of North America 团队就将电脑当作乐器来弹跳、击鼓和演奏的界面,并精心打造了界面。

在 Google 创意实验室的创意指导下,插画师 Rob Bailey 为 JAM 可以使用的 19 种乐器分别创作了精美的插图。根据这些成果,互动总监 Ben Tricklebank 和我们 Tool 的设计团队为每种乐器都创建了简单易用的专业界面。

全即兴剪辑

由于每种乐器的外观都各不相同,因此该工具的技术总监 Bartek Drozdz 是我结合使用 PNG 图片、CSS、SVG 和画布元素,将它们拼接在一起。

在保持 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 在页面中的位置。

由于我们需要确定该元素相对于整个窗口的位置(而不仅仅是其父元素),因此这比只查看元素的 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 来取消滚动事件,这种方法非常实用。

在我们处理任何命中检测之前,只要鼠标在 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 和 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。

我们还可以为 Stage 元素添加“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 函数将获取一个 canvas 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;
};

交集和弹奏

字符串的命中区域再次变成一个框。点击该框应触发字符串动画。但谁想点击吉他呢?

要添加弹奏,我们需要选中 String 框的交集以及用户的鼠标移动的线条。

为了获得鼠标之前位置与当前位置之间的足够距离,我们需要减慢获取鼠标移动事件的速率。在本示例中,我们仅设置一个标记,将 mousemove 事件忽略 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;
};

最后,我们将添加一个新函数来创建字符串乐器。它将创建新的 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);
  }
};

小结

使用共同的场景元素来处理我们的所有互动并非无缺点。它计算起来更为复杂,并且光标指针事件会受到限制,无需添加额外的代码来更改它们。不过,对于 Chrome 中的 JAM 来说,能够将鼠标事件从各个元素中提取出来的优势非常好。它让我们能够对界面设计进行更多实验,在为元素添加动画效果的方法之间切换,使用 SVG 替换基本形状的图片,轻松停用命中区域等。

要查看实际的鼓和音符效果,请启动您自己的 JAM,然后选择 Standard Drums(标准鼓)或 Classic Clean Electric Guitar(经典清洁电吉他)。

Jam 徽标