دراسة حالة - بناء متسابق

مقدمة

Racer هي تجربة Chrome متوافقة مع الأجهزة الجوّالة ومستندة إلى الويب، تم تطويرها من قِبل Active Theory. يمكن لما يصل إلى 5 أصدقاء ربط هواتفهم أو أجهزتهم اللوحية للتسابق على كل شاشة. بعد الحصول على الفكرة والتصميم والنموذج الأولي من Google Creative Lab والصوت من Plan8، أجرينا تكرارات على النُسخ على مدار 8 أسابيع قبل الإطلاق في مؤتمر I/O لعام 2013. بعد أن أصبحت اللعبة متاحة منذ بضعة أسابيع، سنجيب في هذه المقالة عن بعض الأسئلة التي تلقّيناها من منتدى المطوّرين حول آلية عملها. في ما يلي تفاصيل عن الميزات الرئيسية وإجابات عن الأسئلة الأكثر شيوعًا.

المسار

كان أحد التحديات الواضحة التي واجهناها هو كيفية إنشاء لعبة جوّالة مستندة إلى الويب تعمل بشكل جيد على مجموعة كبيرة من الأجهزة. كان على اللاعبين أن يتمكّنوا من إنشاء سباق باستخدام هواتف وأجهزة لوحية مختلفة. يمكن أن يكون لدى أحد اللاعبين هاتف Nexus 4 ويريد أن يتسابق مع صديقه الذي لديه جهاز iPad. لذلك، كان علينا ابتكار طريقة لتحديد حجم مسار مشترك لكل سباق. كان على الحلّ أن يتضمّن استخدام مسارات مختلفة الأحجام استنادًا إلى مواصفات كل جهاز مضمّن في السباق.

احتساب سمات الأغنية

عند انضمام كل لاعب، يتم إرسال معلومات عن جهازه إلى الخادم ومشاركتها مع اللاعبين الآخرين. عند إنشاء المسار، يتم استخدام هذه البيانات لاحتساب ارتفاعه وعرضه. نحسب الارتفاع من خلال احتساب ارتفاع أصغر شاشة، والعرض هو إجمالي عرض جميع الشاشات. في المثال أدناه، سيكون عرض المقطع الصوتي 1152 بكسل وارتفاعه 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;
  }
}
ويمكن بعد ذلك استخدام الإزاحة x لعرض الجزء المناسب من المقطع الصوتي.
يمكن بعد ذلك استخدام الإزاحة على محور x لعرض الجزء المناسب من المقطع الصوتي

الصور المتحركة في CSS

يستخدم Paper.js الكثير من معالجة وحدة المعالجة المركزية لرسم مسارات المسار، وستستغرق هذه العملية وقتًا أطول أو أقصر على الأجهزة المختلفة. لحلّ هذه المشكلة، احتجنا إلى أداة تحميل لتشغيل المحتوى بشكل متكرّر إلى أن تنتهي جميع الأجهزة من معالجة المقطع الصوتي. كانت المشكلة أنّ أيّ صورة متحركة مستندة إلى JavaScript كانت تتخطّى اللقطات بسبب متطلبات وحدة المعالجة المركزية في Paper.js. أدخِل الصور المتحركة في CSS التي يتم تشغيلها في سلسلة محادثات منفصلة لواجهة المستخدم، ما يتيح لنا إضافة تأثيرات متحركة سلسة على اللمعان في نص "BUILDING TRACK".

.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 مفيدة أيضًا في إضافة المؤثرات داخل اللعبة. تُستخدَم الطاقة المحدودة للأجهزة الجوّالة في إضافة الحركة إلى السيارات التي تسير على المسارات. لذلك، أضفنا عناصر متحركة كطريقة لتنفيذ الرسوم المتحركة المعروضة مسبقًا في اللعبة. في صورة متحركة صغيرة بلغة CSS، تُطبِّق الانتقالات صورة متحركة مستندة إلى الخطوات تُغيّر السمة 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 ملي ثانية، ولكن قد يستغرق اللاعب 2 50 ملي ثانية لتلقّيها. سيؤدي ذلك إلى ظهور السيارة في مكانَين مختلفَين لأنّ الجهاز 1 سيبدأ التسارع في وقت أقرب.

يمكننا أخذ الوقت الذي استغرقه استلام الحدث وتحويله إلى إطارات. عند 60 لقطة في الثانية، تكون مدة كل لقطة 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 حدود تكنولوجيا المتصفّح بطريقة ممتعة، ولم نكن نتمنّى الحصول على المزيد من المطوّرين.