案例研究 - JAM with Chrome

如何打造出出色的界面

Fred Chasen
Fred Chasen

简介

Chrome 音乐创作是 Google 推出的一项基于网络的音乐项目。借助 Chrome 的 JAM 功能,世界各地的用户都可以组建乐队,并在浏览器中实时 JAM 音乐。DinahMoe 突破了 Chrome 的 Web Audio API 的使用限制,Tool of North America 团队打造了界面,让您可以像弹奏乐器一样弹奏、敲击和演奏计算机。

在 Google Creative Lab 的创意指导下,插画家 Rob Bailey 为可用于 JAM 的 19 种乐器创作了精致的插图。在此基础上,互动总监 Ben Tricklebank 和我们的工具设计团队为每种乐器都打造了简单易用的专业界面。

完整的即兴演奏蒙太奇

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

许多乐器都必须处理不同的互动方式(例如点击、拖动和弹拨 - 您预计使用乐器执行的所有操作),同时保持 DinahMoe 音效引擎的界面不变。我们发现,仅使用 JavaScript 的 mouseup 和 mousedown 并不足以提供出色的游戏体验。

为了应对所有这些变化,我们创建了一个覆盖可玩区域的“舞台”元素,用于处理所有不同乐器的点击、拖动和弹拨操作。

舞台

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 取消滚动事件的延迟的文章,非常适合此处使用。

在处理任何点击检测之前,此第一个示例只会在鼠标在 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、设置多个字符串,并获取将在其上绘制的画布的上下文。

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 Irish 的文章 requestAnimationFrame 用于智能动画,详细了解 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 徽标