מבוא
Racer הוא ניסוי מבוסס-אינטרנט לנייד ב-Chrome שפותח על ידי Active Theory. עד 5 חברים יכולים לחבר את הטלפונים או הטאבלטים שלהם כדי להתחרות בכל המסכים. בעזרת הקונספט, העיצוב והאב טיפוס מ-Google Creative Lab והסאונד מ-Plan8, שיפרנו את הגרסאות הזמניות במשך 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 Canvas. הגענו למסקנה ש-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;
}
}

אנימציות CSS
ב-Paper.js נעשה שימוש רב בעיבוד מעבד כדי לצייר את נתיבי המסלול, והתהליך הזה ייקח יותר או פחות זמן במכשירים שונים. כדי לטפל בבעיה הזו, נדרשה טעינה חוזרת (loop) עד שכל המכשירים יסתיימו עיבוד הטראק. הבעיה הייתה שכל אנימציה שמבוססת על 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 sprite
ה-CSS גם עזר לנו ליצור אפקטים במשחק. המכשירים הניידים, עם הכוח המוגבל שלהם, עסוקים ביצירת אנימציה של הרכבות שפועלות על המסילה. כדי להוסיף עוד עניין, השתמשנו ב-sprites כדרך להטמיע אנימציות שעברן עיבוד מראש במשחק. ב-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 {
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;
}
}
עיבוד המכוניות
כמו בכל משחק מירוצי מכוניות, ידענו שחשוב לתת למשתמש תחושה של תאוצה ובקרה. חשוב היה להחיל כמות שונה של אחיזה כדי לאזן את המשחק ולשפר את גורם ההנאה, כך שכשהשחקן יבין את הפיזיקה, הוא ירגיש תחושת הישג ויהיה נהג טוב יותר.
שוב השתמשנו ב-Paper.js, שמגיע עם קבוצה נרחבת של כלי חשבון. השתמשנו בחלק מהשיטות שלו כדי להזיז את הרכב לאורך המסלול, תוך התאמה חלקה של המיקום והסיבוב של הרכב בכל פריים.
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 אלפיות השנייה, אבל ייתכן שיחלפו 50 אלפיות השנייה עד ששחקן 2 יקבל אותה. כתוצאה מכך, הרכב יופיע בשני מיקומים שונים כי המכשיר הראשון יתחיל את התאוצה מוקדם יותר.
אנחנו יכולים להשתמש בזמן שחלף עד לקבלת האירוע ולהמיר אותו לפריימים. בקצב של 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 דחף את גבולות הטכנולוגיה של הדפדפן בצורה מהנה, וכמפתחים, לא יכולנו לבקש יותר.