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