مطالعه موردی - ساختمان مسابقه

معرفی

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 از پردازش CPU زیادی برای ترسیم خطوط مسیر استفاده می کند و این فرآیند در دستگاه های مختلف زمان کم و بیش طول می کشد. برای انجام این کار، ما به یک لودر نیاز داشتیم تا زمانی که همه دستگاه‌ها پردازش مسیر را به پایان برسانند، حلقه بزند. مشکل این بود که هر انیمیشن مبتنی بر جاوا اسکریپت فریم ها را به دلیل نیازهای CPU 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 Sprites

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 که روی یک ردیف قرار گرفته اند استفاده کنید. به منظور حلقه زدن از طریق چندین ردیف، انیمیشن باید از طریق چندین اعلان فریم کلیدی زنجیره شود.

#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 for 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 + ')';

همگام نگه داشتن دستگاه ها

مهمترین (و دشوار) بخش توسعه، اطمینان از همگام سازی بازی بین دستگاه ها بود. ما فکر می‌کردیم که کاربران می‌توانند ببخشند اگر اتومبیلی گهگاه به دلیل اتصال آهسته چند فریم را رد می‌کند، اما اگر ماشین شما در حال پریدن است و همزمان روی چند صفحه نمایش ظاهر می‌شود، چندان جالب نخواهد بود. حل این مشکل به آزمون و خطای زیادی نیاز داشت، اما ما در نهایت به چند ترفند که باعث شد آن کار کند، اکتفا کردیم.

محاسبه تاخیر

نقطه شروع برای همگام سازی دستگاه ها دانستن مدت زمان دریافت پیام ها از رله موتور محاسباتی است. بخش مشکل این است که ساعت های هر دستگاه هرگز به طور کامل هماهنگ نمی شوند. برای دور زدن این موضوع، باید تفاوت زمان بین دستگاه و سرور را پیدا کنیم.

برای یافتن فاصله زمانی بین دستگاه و سرور اصلی، پیامی با مهر زمانی دستگاه فعلی ارسال می‌کنیم. سپس سرور با مهر زمانی اصلی همراه با مهر زمانی سرور پاسخ خواهد داد. ما از پاسخ برای محاسبه تفاوت واقعی در زمان استفاده می کنیم.

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

در مثال بالا، اگر Player 1 ماشین را روی صفحه نمایش خود داشته باشد و زمان دریافت پیام کمتر از 75ms باشد، سرعت ماشین را تنظیم می کند و سرعت آن را افزایش می دهد تا تفاوت را جبران کند. اگر دستگاه روی صفحه نمایش نباشد یا پیام خیلی طولانی شود، عملکرد رندر را اجرا می کند و در واقع باعث می شود ماشین به جایی که باید باشد بپرد.

همگام سازی خودروها

حتی پس از محاسبه تأخیر در شتاب، خودرو همچنان می‌تواند از هماهنگی خارج شود و همزمان در چندین صفحه نمایش داده شود. به ویژه هنگام انتقال از یک دستگاه به دستگاه دیگر. برای جلوگیری از این امر، رویدادهای به‌روزرسانی مکرر ارسال می‌شود تا خودروها در همه صفحه‌ها در موقعیت مشابهی در مسیر قرار بگیرند.

منطق این است که هر 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 محدودیت‌های فناوری مرورگر را به شیوه‌ای سرگرم‌کننده افزایش داد و به عنوان توسعه‌دهندگان نمی‌توانستیم درخواست بیشتری داشته باشیم.