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

مقدمة

Racer هي تجربة في Chrome للأجهزة الجوّالة مستندة إلى الويب تم تطويرها من قِبل Active Theory. بإمكان 5 أصدقاء بحد أقصى توصيل هواتفهم أو أجهزتهم اللوحية للمنافسة على مختلف الشاشات. وبفضل المفهوم والتصميم والنموذج الأولي من مختبر Google الإبداعي والصوت من 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. تبيّن لنا أنّ Paper.js هي الأداة المثالية لإنشاء أشكال متّجهات للمسارات، لذلك استخدمنا إمكاناته لعرض مسارات SVG التي تم إنشاؤها في Adobe Illustrator على عنصر <canvas>. لإنشاء المسار، تُلحق الفئة TrackModel رمز SVG بنموذج SVG وتجمع معلومات عن السمات الأصلية والموضع الذي سيتم تمريره إلى 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 الكثير من معالجة وحدة المعالجة المركزية (CPU) لرسم ممرات الممر وستستغرق هذه العملية وقتًا أكثر أو أقل على الأجهزة المختلفة. لمعالجة هذا الأمر، احتجنا إلى تكرار حل المشكلة حتى تنتهي جميع الأجهزة من معالجة المسار. وكانت المشكلة أن أي رسم متحرك مستند إلى JavaScript سيتخطى الإطارات بسبب متطلبات وحدة المعالجة المركزية 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 المدمجة

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