מקרה לדוגמה – JAM עם Chrome

איך שיפרנו את ממשק המשתמש

מבוא

JAM with Chrome הוא פרויקט מוזיקלי מבוסס-אינטרנט שנוצר על ידי Google. JAM with Chrome מאפשר לאנשים מכל העולם ליצור להקה ולנגן בה בזמן אמת בדפדפן. DinahMoe דחפו את הגבולות של מה שאפשר לעשות באמצעות Web Audio API של Chrome, והצוות שלנו ב-Tool of North America יצר את הממשק לנגינה בגיטרה, בתופים ובמחשב כאילו מדובר בכלי מוזיקלי.

בהנחיית צוות Google Creative Lab, המאייר רוב באילי (Rob Bailey) יצר איורים מורכבים לכל אחד מ-19 הכלים שזמינים ליצירת JAM. על סמך הנתונים האלה, הבמאי האינטראקטיבי בן טריקלבנק וצוות העיצוב שלנו ב-Tool יצרו ממשק קל ומקצועי לכל מכשיר.

מונטהז' מלא של מסמך ה-Jam

כל כלי הוא ייחודי מבחינה ויזואלית, ולכן Bartek Drozdz, מנהל הטכני של Tool, ואני צירפנו אותם יחד באמצעות שילובים של תמונות PNG, CSS, SVG ורכיבי Canvas.

רבים מהכלים היו צריכים לטפל בשיטות אינטראקציה שונות (כמו קליקים, גרירה ונגינה בפריט – כל מה שאפשר לעשות עם כלי), תוך שמירה על הממשק עם מנוע הקול של DinahMoe. הבנו שאנחנו צריכים יותר מאשר רק את האירועים mouseup ו-mousedown ב-JavaScript כדי לספק חוויית משחק יפה.

כדי להתמודד עם כל המגוון הזה, יצרנו רכיב 'במה' שכיסה את אזור הנגינה, טיפל בלחיצות, בגרירה ובנגינה בכל הכלים השונים.

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

אחזור המיקום של הרכיב והעכבר

המשימה הראשונה שלנו היא לתרגם את קואורדינטות העכבר בחלון הדפדפן כך שיהיו יחסיות לרכיב הבמה שלנו. כדי לעשות זאת, נדרשנו להביא בחשבון את המיקום של הבמה בדף.

מכיוון שאנחנו צריכים למצוא את המיקום של הרכיב ביחס לחלון כולו, ולא רק ביחס לרכיב ההורה שלו, התהליך קצת יותר מורכב מאשר פשוט לבדוק את הרכיבים 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");

זיהוי פשוט של היטים

ב-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.

אפשר גם להוסיף לרכיב הבמה את הכיתה 'פעיל', וכך סמן העכבר ישתנה לאצבע כשתעברו מעל הריבוע.

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

במקום לשנות את הצבע, הוספת סיווג ההיט תפעיל אנימציה של 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 שלנו תקבל מזהה של לוח ציור ואובייקט Rect ותצייר קו במרכז המת rectangle.

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

צמתים ופריטה

אזור ההיט של המחרוזת יהיה שוב רק תיבה. לחיצה בתוך התיבה הזו אמורה להפעיל את אנימציית המחרוזת. אבל מי רוצה ללחוץ על גיטרה?

כדי להוסיף פריטה, צריך לבדוק את הצטלבות התיבה של המיתרים עם הקו שבו העכבר של המשתמש נע.

כדי ליצור מספיק מרחק בין המיקום הקודם של העכבר למיקום הנוכחי שלו, נצטרך להאט את הקצב שבו אנחנו מקבלים את אירועי תנועת העכבר. בדוגמה הזו, פשוט נגדיר דגל כדי להתעלם מאירועי 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;
};

לסיום, נוסיף פונקציה חדשה ליצירת כלי מיתר. הפונקציה תיצור את הבמה החדשה, תגדיר מספר מחרוזות ותקבל את ההקשר של לוח הציור שבו תתבצע הציור.

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

בשלב הבא נמקם את אזורי ההקשה של המיתרים ולאחר מכן נוסיף אותם לרכיב הבמה.

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

לבסוף, פונקציית ה-render של StringInstrument תעבור בחזרה על כל המחרוזות ותפעיל את שיטות ה-render שלהן. הוא פועל כל הזמן, במהירות שמתאימה ל-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);
  }
};

סיכום

יש חסרונות לשימוש ברכיב Stage משותף לטיפול בכל האינטראקציות שלנו. האירועים מורכבים יותר מבחינה חישובית, ואירועי סמן הסמן מוגבלים ללא הוספת קוד נוסף לשינוי שלהם. עם זאת, ב-JAM עם Chrome, היתרונות של היכולת להפריד אירועי עכבר מהרכיבים הנפרדים עבדו מצוין. היא מאפשרת לנו להתנסות יותר בעיצוב הממשק, לעבור בין שיטות של אנימציה של רכיבים, להשתמש ב-SVG כדי להחליף תמונות של צורות בסיסיות, להשבית בקלות אזורים של אירועים ועוד.

כדי לראות את התכונות 'תופים' ו'קטעים קצרים' בפעולה, פותחים JAM משלכם ובוחרים באפשרות תופים רגילים או גיטרה חשמלית קלאסית נקייה.

הלוגו של Jam