กรณีศึกษา - JAM with Chrome

วิธีที่เราทำให้ UI เจ๋ง

บทนำ

JAM with Chrome เป็นโปรเจ็กต์ดนตรีบนเว็บที่ Google สร้างขึ้น JAM with Chrome ช่วยให้ผู้คนจากทั่วโลกสามารถรวมกันเป็นวงดนตรีและเล่นดนตรีร่วมกันแบบเรียลไทม์ในเบราว์เซอร์ DinahMoe ขยายขีดความสามารถของ Web Audio API ของ Chrome ทีมของเราที่ Tool of North America ได้สร้างอินเทอร์เฟซสำหรับการดีดกีตาร์ ตีกลอง และเล่นคอมพิวเตอร์ราวกับเป็นเครื่องดนตรี

นักวาดภาพ Rob Bailey ได้สร้างภาพประกอบที่ซับซ้อนสำหรับเครื่องดนตรีแต่ละชิ้นทั้ง 19 รายการที่มีให้เล่นใน JAM โดยได้รับครีเอทีฟไดเรกชันจาก Google Creative Lab Ben Tricklebank ผู้กำกับอินเทอร์แอกทีฟและทีมออกแบบของ Tool จึงสร้างอินเทอร์เฟซที่ใช้งานง่ายและมืออาชีพสำหรับเครื่องมือแต่ละรายการ

การตัดต่อการแ Jam แบบเต็ม

เนื่องจากเครื่องดนตรีแต่ละชิ้นมีเอกลักษณ์เฉพาะตัว Bartek Drozdz ซึ่งเป็นผู้อำนวยการด้านเทคนิคของ Tool และฉันจึงต่อภาพเหล่านี้เข้าด้วยกันโดยใช้องค์ประกอบรูปภาพ PNG, CSS, SVG และ Canvas

เครื่องดนตรีหลายชนิดต้องจัดการกับการโต้ตอบด้วยวิธีต่างๆ (เช่น การคลิก การลาก และการดีด ทุกอย่างที่คุณคาดหวังจะทำกับเครื่องดนตรี) ในขณะเดียวกันก็ต้องทำให้อินเทอร์เฟซกับโปรแกรมสร้างเสียงของ DinahMoe เหมือนเดิม เราพบว่าต้องใช้มากกว่าแค่ mouseup และ mousedown ของ 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 ใหม่และส่งรหัสของ div ที่ต้องการใช้เป็น Stage

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

การตรวจหา Hit แบบง่าย

ใน 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 ของเมาส์ที่ส่งมานั้นตรงกับรูปร่างหรือไม่ และแสดงผลเป็นจริงหรือเท็จ

นอกจากนี้ เรายังเพิ่มคลาส "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

โชคดีที่การสร้าง JAM ด้วย Chrome ทำให้เราไม่ต้องใช้รูปร่างอื่นนอกเหนือจากวงกลมและสี่เหลี่ยมผืนผ้า โดยอาศัยการผสมผสานรูปร่างและการวางซ้อนเพื่อจัดการกับความซับซ้อนเพิ่มเติม

รูปทรงกลอง

วงกลม

หากต้องการตรวจสอบว่าจุดหนึ่งๆ อยู่ภายในถังทรงกลมหรือไม่ เราจะต้องสร้างรูปร่างฐานวงกลม แม้ว่าจะคล้ายกับสี่เหลี่ยมผืนผ้า แต่ก็มีวิธีการของตัวเองในการกำหนดขอบเขตและตรวจสอบว่าจุดอยู่ภายในวงกลมหรือไม่

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

การเพิ่มคลาส Hit จะทริกเกอร์ภาพเคลื่อนไหว 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 และออบเจ็กต์ 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;
};

จุดตัดและการดีด

พื้นที่ทํางานของสตริงจะเป็นกล่องอีกครั้ง การคลิกภายในช่องดังกล่าวควรเรียกให้ภาพเคลื่อนไหวสตริงแสดง แต่ใครอยากคลิกกีตาร์บ้าง

หากต้องการเพิ่มการดีดนิ้ว เราต้องตรวจสอบจุดตัดของช่องสตริงกับเส้นที่เมาส์ของผู้ใช้ลากไป

หากต้องการให้มีระยะห่างเพียงพอระหว่างตำแหน่งก่อนหน้าและปัจจุบันของเมาส์ เราจะต้องลดอัตราการรับเหตุการณ์การเลื่อนเมาส์ ในตัวอย่างนี้ เราจะตั้งค่า Flag เพื่อละเว้นเหตุการณ์ 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;
};

สุดท้าย เราจะเพิ่มฟังก์ชันใหม่เพื่อสร้างเครื่องสาย การดำเนินการนี้จะสร้างเวทีใหม่ ตั้งค่าสตริงจำนวนหนึ่ง และรับบริบทของ 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 ได้ในบทความ requestAnimationFrame สําหรับภาพเคลื่อนไหวอัจฉริยะของ Paul Irish

ในแอปพลิเคชันจริง คุณอาจต้องการตั้งค่า Flag เมื่อไม่มีภาพเคลื่อนไหวเกิดขึ้นเพื่อหยุดวาดเฟรม Canvas ใหม่

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 ที่ใช้ Chrome ประโยชน์ของการแยกเหตุการณ์เมาส์ออกจากองค์ประกอบแต่ละรายการนั้นได้ผลดีมาก ซึ่งช่วยให้เราทดสอบการออกแบบอินเทอร์เฟซได้มากขึ้น สลับระหว่างวิธีการสร้างภาพเคลื่อนไหวขององค์ประกอบ ใช้ SVG เพื่อแทนที่รูปภาพรูปร่างพื้นฐาน ปิดใช้พื้นที่ที่ผู้ใช้แตะได้ และอื่นๆ อีกมากมาย

หากต้องการดูว่าเสียงกลองและสตริงทำงานอย่างไร ให้เริ่มJAM ของคุณเอง แล้วเลือกกลองมาตรฐานหรือกีตาร์ไฟฟ้าเสียงคลีนคลาสสิก

โลโก้ Jam