מקרה לדוגמה – בניית מרוץ

Active Theory
Active Theory

מבוא

Racer הוא ניסוי מבוסס-אינטרנט לנייד של Chrome שפותח על ידי Active Theory. עד 5 חברים יכולים לחבר את הטלפונים או הטאבלטים שלהם כדי להתחרות במרוץ בכל מסך. בצירוף תפיסה, עיצוב ואב-טיפוס של Google Creative Lab וסאונד של Plan8, ערכנו איטרציה בגרסאות build במשך 8 שבועות לפני ההשקה בכנס I/O ב-2013. עכשיו, לאחר שהמשחק זמין כבר כמה שבועות, הייתה לנו הזדמנות לענות על כמה שאלות מקהילת המפתחים לגבי אופן הפעולה שלו. לפניכם פירוט של התכונות העיקריות ותשובות לשאלות הנפוצות ביותר.

הטראק

אתגר ברור למדי שעמד בפנינו היה כיצד ליצור משחק מבוסס-אינטרנט לנייד שפועל היטב במגוון רחב של מכשירים. השחקנים היו צריכים לדעת איך לתכנן מרוץ עם טלפונים וטאבלטים שונים. לשחקן אחד יכול להיות מכשיר Nexus 4 והוא רוצה להתחרות בחבר שלו שהיה לו iPad. היינו צריכים למצוא דרך לקבוע גודל מסלול משותף לכל מרוץ. הפתרון היה כרוך בשימוש במסלולים בגדלים שונים בהתאם למפרט של כל מכשיר שנכלל במרוץ.

חישוב מאפייני המעקב

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

האזור האדום מציין את הרוחב והגובה הכוללים של המסלול בדוגמה זו.
האזור האדום מציג את הרוחב והגובה הכוללים של הטראק בדוגמה הזו.
this.getDimensions = function () {
  var response = {};
  response.width = 0;
  response.height = _gamePlayers[0].scrn.h; // First screen height
  response.screens = [];
  
  for (var i = 0; i < _gamePlayers.length; i++) {
    var player = _gamePlayers[i];
    response.width += player.scrn.w;

    if (player.scrn.h < response.height) {
      // Find the smallest screen height
      response.height = player.scrn.h;
    }
      
    response.screens.push(player.scrn);
  }
  
  return response;
}

ציור המסלול

Paper.js היא סביבת סקריפט גרפית וקטורית בקוד פתוח שפועלת על לוח הציור של HTML5. גילינו ש-Paper.js היה הכלי המושלם ליצירת צורות וקטוריות עבור מסלולים, לכן השתמשנו ביכולות שלו כדי לעבד את רצועות ה-SVG שנבנו ב-Adobe Illustrator על אלמנט <canvas>. כדי ליצור את הטראק, המחלקה TrackModel מצרפת את קוד ה-SVG ל-DOM ואוספת מידע על המימדים והמיקום המקוריים להעברה אל TrackPathView. פעולה זו תצייר את המסלול על קנבס.

paper.install(window);
_paper = new paper.PaperScope();
_paper.setup('track_canvas');
                    
var svg = document.getElementById('track');
var layer = new _paper.Layer();

_path = layer.importSvg(svg).firstChild.firstChild;
_path.strokeColor = '#14a8df';
_path.strokeWidth = 2;

לאחר שרטוט המסלול, כל מכשיר מוצא את היסט x שלו על סמך מיקומו בסדר ההצגה של המכשירים, וממקם את המסלול בהתאם.

var x = 0;

for (var i = 0; i < screens.length; i++) {
  if (i < PLAYER_INDEX) {
    x += screens[i].w;
  }
}
לאחר מכן ניתן להשתמש בהיסט x כדי להציג את החלק המתאים של הטראק.
לאחר מכן אפשר להשתמש בהיסט x כדי להציג את החלק המתאים של הטראק

אנימציות CSS

ב-Paperform, נעשה שימוש רב בעיבוד של המעבד (CPU) כדי לשרטט את המסלולים, והתהליך הזה יארך יותר או פחות זמן במכשירים שונים. כדי לטפל בבעיה, היה צורך במכשיר טעינה בלולאה עד שכל המכשירים יסיימו את עיבוד המסלול. הבעיה הייתה שכל אנימציה מבוססת JavaScript הייתה מדלגת על פריימים עקב דרישות המעבד (CPU) של Paper.js. אפשר להזין אנימציות CSS, שפועלות בשרשור נפרד של ממשק המשתמש, וכך מאפשרות לנו להוסיף אנימציה בצורה חלקה לכל טקסט "טראק הבניין".

.glow {
  width: 290px;
  height: 290px;
  background: url('img/track-glow.png') 0 0 no-repeat;
  background-size: 100%;
  top: 0;
  left: -290px;
  z-index: 1;
  -webkit-animation: wipe 1.3s linear 0s infinite;
}

@-webkit-keyframes wipe {
  0% {
    -webkit-transform: translate(-300px, 0);
  }

  25% {
    -webkit-transform: translate(-300px, 0);
  }

  75% {
    -webkit-transform: translate(920px, 0);
  }

  100% {
    -webkit-transform: translate(920px, 0);
  }
}
}

תמונות CSS Sprites

שירות ה-CSS היה שימושי גם לאפקטים בתוך המשחק. ניידים, עם עוצמה מוגבלת, עסוקים באנימציה של המכוניות שנוסעים על המסילה. לשם ריגוש נוסף, השתמשנו ב-Sprite כדרך להטמיע במשחק אנימציות שעברו עיבוד מראש. ב-CSS Sprite, מעברים מפעילים אנימציה מבוססת-שלב שמשנה את המאפיין background-position ויוצרת פיצוץ מכונית.

#sprite {
  height: 100px; 
  width: 100px;
  background: url('sprite.jpg') 0 0 no-repeat;
  -webkit-animation: play-sprite 0.33s linear 0s steps(9) infinite;
}

@-webkit-keyframes play-sprite {
  0% {
    background-position: 0 0;
  }

  100% {
    background-position: -900px 0;
  }
}

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

#sprite {
  height: 100px; 
  width: 100px;
  background: url('sprite.jpg') 0 0 no-repeat;
  -webkit-animation-name: row1, row2, row3;
  -webkit-animation-duration: 0.2s;
  -webkit-animation-delay: 0s, 0.2s, 0.4s;
  -webkit-animation-timing-function: steps(5), steps(5), steps(5);
  -webkit-animation-fill-mode: forwards;
}

@-webkit-keyframes row1 {
  0% {
    background-position: 0 0;
  }

  100% {
    background-position: -500px 0;
  }
}

@-webkit-keyframes row2 {
  0% {
    background-position: 0 -100px;
  }

  100% {
    background-position: -500px -100px;
  }
}

@-webkit-keyframes row3 {
  0% {
    background-position: 0 -200px;
  }

  100% {
    background-position: -500px -200px;
  }
}

טיפול במכוניות

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

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

var trackOffset = _path.length - (_elapsed % _path.length);
var trackPoint = _path.getPointAt(trackOffset);
var trackAngle = _path.getTangentAt(trackOffset).angle;

// Apply the throttle
_velocity.length += _throttle;

if (!_throttle) {
  // Slow down since the throttle is off
  _velocity.length *= FRICTION;
}

if (_velocity.length > MAXVELOCITY) {
  _velocity.length = MAXVELOCITY;
}

_velocity.angle = trackAngle;
trackOffset -= _velocity.length;
_elapsed += _velocity.length;

// Find if a lap has been completed
if (trackOffset < 0) {
  while (trackOffset < 0) trackOffset += _path.length;

  trackPoint = _path.getPointAt(trackOffset);
  console.log('LAP COMPLETE!');
}

if (_velocity.length > 0.1) {
  // Render the car if there is actually velocity
  renderCar(trackPoint);
}

במהלך האופטימיזציה של עיבוד הרכבים, גילינו נקודה מעניינת. ב-iOS, הביצועים הטובים ביותר הושגו על ידי החלת הטרנספורמציה translate3d על המכונית:

_car.style.webkitTransform = 'translate3d('+_position.x+'px, '+_position.y+'px, 0px)rotate('+_rotation+'deg)';

ב-Chrome ל-Android, הביצועים הטובים ביותר הושגו על ידי חישוב ערכי המטריצות והחלת התמרת מטריצה:

var rad = _rotation.rotation * (Math.PI * 2 / 360);
var cos = Math.cos(rad);
var sin = Math.sin(rad);
var a = parseFloat(cos).toFixed(8);
var b = parseFloat(sin).toFixed(8);
var c = parseFloat(-sin).toFixed(8);
var d = a;
_car.style.webkitTransform = 'matrix(' + a + ', ' + b + ', ' + c + ', ' + d + ', ' + _position.x + ', ' + _position.y + ')';

שמירה על סנכרון המכשירים

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

חישוב זמן האחזור

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

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

var currentTime = Date.now();
var latency = Math.round((currentTime - e.time) * .5);
var serverTime = e.serverTime;
currentTime -= latency;
var difference = currentTime - serverTime;

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

תאוצה/האצה

כששחקן 1 לוחץ על המסך או משחרר אותו, אירוע ההאצה נשלח לשרת. לאחר הקבלה, השרת מוסיף את חותמת הזמן הנוכחית שלו ומעביר את הנתונים האלה לכל שחקן אחר.

כאשר אירוע "האצה ב-" או "האצה של כיבוי" מתקבל במכשיר, אנחנו יכולים להשתמש בהיסט השרת (שמחושב למעלה) כדי לברר כמה זמן חלף עד שההודעה התקבלה. זה שימושי, כי שחקן 1 עשוי לקבל את ההודעה תוך 20 אלפיות השנייה, אבל שחקן 2 יכול לקבל אותה תוך 50 אלפיות השנייה. כתוצאה מכך, הרכב יהיה בשני מקומות שונים כי מכשיר 1 יתחיל את ההאצה מוקדם יותר.

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

var frames = time / 16.67;
var onScreen = this.isOnScreen() && time < 75;

for (var i = 0; i < frames; i++) {
  if (onScreen) {
    _velocity.length += _throttle * Math.round(frames * .215);
  } else {
    _this.render();
  }
}}

בדוגמה שלמעלה, אם המסך של שחקן 1 מציג את המכונית והזמן שלוקח לקבל את ההודעה הוא פחות מ-75 אלפיות השנייה, הוא יכוונן את מהירות המכונית ומאיץ את המהירות כדי לפצות על ההפרש. אם המכשיר לא על המסך או שההודעה נמשכה יותר מדי זמן, הוא יפעיל את פונקציית העיבוד ויגרום למכונית לקפוץ למיקום הרצוי.

שמירה על סנכרון המכוניות

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

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

this.getValues = function () {
  _values.p = _position.clone();
  _values.r = _rotation;
  _values.e = _elapsed;
  _values.v = _velocity.length;
  _values.pos = _this.position;

  return _values;
}

this.setValues = function (val, time) {
  _position.x = val.p.x;
  _position.y = val.p.y;
  _rotation = val.r;
  _elapsed = val.e;
  _velocity.length = val.v;

  var frames = time / 16.67;

  for (var i = 0; i < frames; i++) {
    _this.render();
  }
}

סיכום

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