The Hobbit Experience 2014

افزودن گیم پلی WebRTC به تجربه هابیت

دانیل ایزاکسون
دانیل ایزاکسون

در زمان فیلم جدید هابیت «هابیت: نبرد پنج ارتش»، ما روی گسترش آزمایش کروم سال گذشته، سفری در سرزمین میانه با محتوای جدید کار کرده‌ایم. تمرکز اصلی این بار بر گسترش استفاده از WebGL بوده است زیرا مرورگرها و دستگاه های بیشتری می توانند محتوا را مشاهده کنند و با قابلیت های WebRTC در کروم و فایرفاکس کار کنند. ما با این آزمایش سال سه هدف داشتیم:

  • گیم پلی P2P با استفاده از WebRTC و WebGL در Chrome for Android
  • یک بازی چند نفره بسازید که به راحتی قابل بازی باشد و بر اساس ورودی لمسی باشد
  • میزبانی در Google Cloud Platform

تعریف بازی

منطق بازی بر اساس یک راه‌اندازی شبکه‌ای با نیروها در حال حرکت بر روی صفحه بازی ساخته شده است. این امر باعث شد تا هنگام تعریف قوانین، بازی را روی کاغذ امتحان کنیم. استفاده از راه‌اندازی مبتنی بر شبکه همچنین به تشخیص برخورد در بازی کمک می‌کند تا عملکرد خوبی داشته باشد زیرا فقط باید برخورد با اشیاء را در کاشی‌های مشابه یا همسایه بررسی کنید. ما از ابتدا می دانستیم که می خواهیم بازی جدید را حول نبردی بین چهار نیروی اصلی سرزمین میانه، انسان ها، دورف ها، الف ها و اورک ها متمرکز کنیم. همچنین باید به اندازه کافی معمولی باشد تا در یک آزمایش Chrome پخش شود و تعاملات زیادی برای یادگیری نداشته باشد. ما با تعریف پنج میدان نبرد در نقشه سرزمین میانه شروع کردیم که به عنوان اتاق بازی عمل می کنند که در آن چندین بازیکن می توانند در یک نبرد همتا به همتا به رقابت بپردازند. نمایش چند بازیکن در اتاق روی صفحه موبایل و اجازه دادن به کاربران برای انتخاب اینکه چه کسی را به چالش بکشند به خودی خود یک چالش بود. برای آسان‌تر کردن تعامل و صحنه، تصمیم گرفتیم فقط یک دکمه برای چالش و پذیرش داشته باشیم و فقط از اتاق برای نشان دادن رویدادها و اینکه کی پادشاه فعلی تپه است استفاده کنیم. این جهت همچنین چند مسئله را در طرف مسابقه‌سازی حل کرد و به ما اجازه داد تا بهترین نامزدها را برای یک نبرد مطابقت دهیم. در آزمایش قبلی کروم Cube Slam متوجه شدیم که اگر نتیجه بازی به آن متکی باشد، رسیدگی به تأخیر در یک بازی چند نفره به کار زیادی نیاز دارد. شما دائماً باید حدس بزنید که وضعیت حریف کجا خواهد بود، حریف فکر می‌کند شما در کجا هستید و آن را با انیمیشن‌های موجود در دستگاه‌های مختلف همگام کنید. این مقاله این چالش ها را با جزئیات بیشتری توضیح می دهد. برای اینکه کمی ساده تر شود، این بازی را به صورت نوبتی ساختیم.

منطق بازی بر اساس یک راه‌اندازی شبکه‌ای با نیروها در حال حرکت بر روی صفحه بازی ساخته شده است. این امر باعث شد تا هنگام تعریف قوانین، بازی را روی کاغذ امتحان کنیم. استفاده از راه‌اندازی مبتنی بر شبکه همچنین به تشخیص برخورد در بازی کمک می‌کند تا عملکرد خوبی داشته باشد زیرا فقط باید برخورد با اشیاء را در کاشی‌های مشابه یا همسایه بررسی کنید.

بخش هایی از بازی

برای ساخت این بازی چند نفره چند قسمت کلیدی وجود دارد که باید آنها را می ساختیم:

  • یک API مدیریت بازیکن سمت سرور، کاربران، مسابقه‌سازی، جلسات و آمار بازی را مدیریت می‌کند.
  • سرورهایی برای کمک به برقراری ارتباط بین بازیکنان.
  • یک API برای کنترل سیگنال‌های AppEngine Channels API که برای اتصال و برقراری ارتباط با همه بازیکنان در اتاق‌های بازی استفاده می‌شود.
  • یک موتور بازی جاوا اسکریپت که همگام سازی وضعیت و پیام RTC بین دو بازیکن/همتا را مدیریت می کند.
  • نمای بازی WebGL.

مدیریت بازیکن

برای پشتیبانی از تعداد زیادی بازیکن، ما از اتاق‌های بازی موازی زیادی در هر میدان نبرد استفاده می‌کنیم. دلیل اصلی محدود کردن تعداد بازیکنان در هر اتاق بازی، اجازه دادن به بازیکنان جدید برای رسیدن به بالای جدول در زمان معقول است. این محدودیت همچنین به اندازه شی json که اتاق بازی ارسال شده از طریق Channel API را توصیف می کند و دارای محدودیت 32 کیلوبایت است، مرتبط است. ما باید بازیکنان، اتاق ها، امتیازات، جلسات و روابط آنها را در بازی ذخیره کنیم. برای انجام این کار، ابتدا از NDB برای موجودیت ها استفاده کردیم و از رابط پرس و جو برای مقابله با روابط استفاده کردیم. NDB یک رابط به Google Cloud Datastore است. استفاده از NDB در ابتدا عالی کار می کرد، اما خیلی زود در مورد نحوه استفاده از آن با مشکل مواجه شدیم. پرس و جو در برابر نسخه "متعهد" پایگاه داده اجرا شد (NDB Writes به طور مفصل در این مقاله عمیق توضیح داده شده است) که می تواند چندین ثانیه تاخیر داشته باشد. اما خود نهادها این تاخیر را نداشتند زیرا مستقیماً از حافظه پنهان پاسخ می‌دهند. شاید توضیح آن با چند نمونه کد کمی ساده تر باشد:

// example code to explain our issue with eventual consistency
def join_room(player_id, room_id):
    room = Room.get_by_id(room_id)
    
    player = Player.get_by_id(player_id)
    player.room = room.key
    player.put()
    
    // the player Entity is updated directly in the cache
    // so calling this will return the room key as expected
    player.room // = Key(Room, room_id)

    // Fetch all the players with room set to 'room.key'
    players_in_room = Player.query(Player.room == room.key).fetch()
    // = [] (an empty list of players)
    // even though the saved player above may be expected to be in the
    // list it may not be there because the query api is being run against the 
    // "committed" version and may still be empty for a few seconds

    return {
        room: room,
        players: players_in_room,
    }

پس از افزودن تست‌های واحد، می‌توانیم مشکل را به وضوح ببینیم و از کوئری‌ها فاصله گرفتیم تا به جای آن، روابط را در لیست جدا شده با کاما در memcache نگه داریم. این به نظر کمی هک بود، اما کار کرد و حافظه پنهان AppEngine دارای یک سیستم تراکنش مانند برای کلیدها با استفاده از ویژگی عالی "مقایسه و تنظیم" است، بنابراین اکنون آزمایش ها دوباره با موفقیت انجام شد.

متأسفانه memcache همه رنگین‌کمان‌ها و تک‌شاخ‌ها نیستند، اما دارای محدودیت‌هایی هستند، که قابل توجه‌ترین آن‌ها اندازه مقدار 1 مگابایتی (نمی‌تواند تعداد زیادی اتاق مربوط به میدان جنگ داشته باشد) و انقضای کلید است، یا همانطور که اسناد توضیح می‌دهند:

ما در نظر گرفتیم که از یک فروشگاه کلیدی عالی دیگر، Redis استفاده کنیم. اما در آن زمان راه‌اندازی یک خوشه مقیاس‌پذیر کمی دلهره‌آور بود و از آنجایی که ما ترجیح می‌دهیم روی ایجاد تجربه به جای نگهداری سرورها تمرکز کنیم، این مسیر را دنبال نکردیم. از سوی دیگر، Google Cloud Platform اخیراً یک ویژگی ساده Click-to-Deploy را منتشر کرده است که یکی از گزینه ها Redis Cluster است، بنابراین گزینه بسیار جالبی خواهد بود.

سرانجام Google Cloud SQL را پیدا کردیم و روابط را به MySQL منتقل کردیم. کار زیادی بود اما در نهایت عالی کار کرد، به‌روزرسانی‌ها اکنون کاملاً اتمی هستند و آزمایش‌ها هنوز با موفقیت انجام می‌شوند. همچنین اجرای بازی سازی و حفظ امتیاز را بسیار قابل اعتمادتر کرد.

با گذشت زمان تعداد بیشتری از داده ها به آرامی از NDB و memcache به SQL منتقل شده اند، اما به طور کلی موجودیت های بازیکن، میدان جنگ و اتاق هنوز در NDB ذخیره می شوند در حالی که جلسات و روابط بین آنها همه در SQL ذخیره می شوند.

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

از آنجایی که این یک بازی چند نفره است، می‌خواهیم سایر بازیکنان اتاق را در مورد رویدادهایی مانند "چه کسی وارد یا رفت"، "چه کسی برنده یا شکست خورد" و اگر چالشی برای پذیرش وجود دارد، مطلع کنیم. برای رسیدگی به این موضوع، توانایی دریافت اعلان‌ها را در API مدیریت پخش‌کننده ایجاد کردیم.

راه اندازی WebRTC

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

چندین کتابخانه شخص ثالث وجود دارد که می توانید برای خدمات سیگنالینگ استفاده کنید و همچنین راه اندازی WebRTC را ساده می کند. برخی از گزینه ها PeerJS ، SimpleWebRTC و PubNub WebRTC SDK هستند. PubNub از یک راه حل سرور میزبانی شده استفاده می کند و برای این پروژه می خواستیم روی پلتفرم ابری Google میزبانی کنیم. دو کتابخانه دیگر از سرورهای node.js استفاده می‌کنند که می‌توانستیم آنها را روی Google Compute Engine نصب کنیم، اما همچنین باید مطمئن شویم که می‌تواند هزاران کاربر همزمان را مدیریت کند، کاری که قبلاً می‌دانستیم Channel API می‌تواند انجام دهد.

یکی از مزایای اصلی استفاده از Google Cloud Platform در این مورد، مقیاس‌پذیری است. مقیاس کردن منابع مورد نیاز برای پروژه AppEngine به راحتی از طریق Google Developers Console انجام می شود و در هنگام استفاده از Channels API نیازی به کار اضافی برای مقیاس کردن سرویس سیگنالینگ نیست.

نگرانی‌هایی در مورد تأخیر و قوی بودن Channels API وجود داشت، اما ما قبلاً از آن برای پروژه CubeSlam استفاده کرده بودیم و ثابت کرده بود که برای میلیون‌ها کاربر در آن پروژه کار می‌کند، بنابراین تصمیم گرفتیم دوباره از آن استفاده کنیم.

از آنجایی که ما استفاده از کتابخانه شخص ثالث را برای کمک به WebRTC انتخاب نکردیم، مجبور شدیم کتابخانه خود را بسازیم. خوشبختانه ما توانستیم از بسیاری از کارهایی که برای پروژه CubeSlam انجام دادیم دوباره استفاده کنیم. هنگامی که هر دو بازیکن به یک جلسه ملحق شدند، جلسه روی "فعال" تنظیم می شود و هر دو بازیکن از شناسه جلسه فعال برای شروع اتصال همتا به همتا از طریق Channel API استفاده می کنند. پس از آن تمام ارتباطات بین دو بازیکن از طریق یک RTCDataChannel انجام می شود.

ما همچنین به سرورهای STUN و TURN برای کمک به برقراری ارتباط و مقابله با NAT ها و فایروال ها نیاز داریم. درباره راه اندازی WebRTC در مقاله HTML5 Rocks WebRTC در دنیای واقعی بیشتر بخوانید: STUN، TURN، و سیگنالینگ .

تعداد سرورهای TURN مورد استفاده نیز باید بسته به ترافیک مقیاس شود. برای انجام این کار ، Google Deployment Manager را آزمایش کردیم. این به ما امکان می دهد منابع را به صورت پویا در موتور محاسباتی Google مستقر کنیم و سرورهای TURN را با استفاده از یک الگو نصب کنیم. هنوز در آلفا است، اما برای اهداف ما بی عیب و نقص کار می کند. برای سرور TURN ما از coturn استفاده می کنیم که اجرای بسیار سریع، کارآمد و به ظاهر قابل اعتماد STUN/TURN است.

کانال API

Channel API برای ارسال تمام ارتباطات به و از اتاق بازی در سمت مشتری استفاده می شود. API مدیریت پخش‌کننده ما از Channel API برای اعلان‌های مربوط به رویدادهای بازی استفاده می‌کند.

کار با Channels API چند سرعت بالا داشت. یک مثال این است که از آنجایی که پیام ها ممکن است نامرتب باشند، مجبور شدیم همه پیام ها را در یک شی بپیچانیم و آنها را مرتب کنیم. در اینجا چند کد نمونه در مورد نحوه عملکرد آن آورده شده است:

var que = [];  // [seq, packet...]
var seq = 0;
var rcv = -1;

function send(message) {
  var packet = JSON.stringify({
    seq: seq++,
    msg: message
  });
  channel.send(packet);
}

function recv(packet) {
  var data = JSON.parse(packet);

  if (data.seq <= rcv) {
    // ignoring message, older or already received
  } else if (data.seq > rcv + 1) {
    // message from the future. queue it up.
    que.push(data.seq, packet);
  } else {
    // message in order! update the rcv index and emit the message
    rcv = data.seq;
    emit('message', data.message);

    // and now that we have updated the `rcv` index we 
    // will check the que for any other we can send
    setTimeout(flush, 10);
  }
}

function flush() {
  for (var i=0; i<que.length; i++) {
    var seq = que[i];
    var packet = que[i+1];
    if (data.seq == rcv + 1) {
      recv(packet);
      return; // wait for next flush
    }
  }
}

ما همچنین می خواستیم API های مختلف سایت را ماژولار نگه داریم و از میزبانی سایت جدا شده و با استفاده از ماژول های ساخته شده در GAE شروع به کار کردیم. متأسفانه پس از اینکه همه آن را در برنامه‌نویس به کار انداختیم، متوجه شدیم که Channel API در تولید با ماژول‌ها اصلا کار نمی‌کند . در عوض ما به استفاده از نمونه‌های جداگانه GAE رفتیم و با مشکلات CORS مواجه شدیم که ما را مجبور به استفاده از یک iframe postMessage bridge کرد.

موتور بازی

برای اینکه موتور بازی تا حد امکان پویا باشد، ما اپلیکیشن جلویی را با استفاده از رویکرد سیستم entity-component (ECS) ساختیم. زمانی که ما توسعه را شروع کردیم، وایرفریم ها و مشخصات عملکردی تنظیم نشدند، بنابراین بسیار مفید بود که بتوانیم ویژگی ها و منطق را با پیشرفت توسعه اضافه کنیم. به عنوان مثال، اولین نمونه اولیه از یک سیستم رندر بوم ساده برای نمایش موجودیت ها در یک شبکه استفاده کرد. چند بار بعد ، یک سیستم برای برخوردها و یکی برای بازیکنان کنترل شده با هوش مصنوعی اضافه شد. در اواسط پروژه می‌توانستیم بدون تغییر بقیه کدها، به یک سیستم رندر سه بعدی سوئیچ کنیم. هنگامی که بخش های شبکه آماده و در حال اجرا بودند، سیستم ai را می توان برای استفاده از دستورات از راه دور تغییر داد.

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

اگر فقط دو کاربر نوبت را عوض می‌کردند، هر دو همتا می‌توانستند مسئولیت انتقال نوبت را به حریف پس از اتمام کار تقسیم کنند، اما بازیکن سومی درگیر است. هنگامی که ما نیاز به اضافه کردن دشمنانی مانند عنکبوت ها و ترول ها داشتیم، سیستم هوش مصنوعی دوباره مفید شد (نه فقط برای آزمایش). برای اینکه آنها را در جریان نوبتی قرار دهند باید تخم ریزی شده و دقیقاً به همان صورت در هر دو طرف اجرا شود. این با اجازه دادن به یکی از همتایان کنترل سیستم چرخشی و ارسال وضعیت فعلی به همتای راه دور حل شد. سپس وقتی نوبت عنکبوت‌ها می‌رسد، مدیر نوبت به سیستم ai اجازه می‌دهد دستوری را ایجاد کند که برای کاربر راه دور ارسال می‌شود. از آنجایی که موتور بازی فقط بر روی دستورات و entity-id:s عمل می‌کند، بازی در هر دو طرف شبیه‌سازی خواهد شد. همه واحدها همچنین می توانند دارای مولفه ai باشند که تست خودکار آسان را امکان پذیر می کند.

بهینه بود که در ابتدای توسعه، با تمرکز بر منطق بازی، یک بوم رندر ساده‌تر داشته باشیم. اما لذت واقعی از زمانی شروع شد که نسخه 3 بعدی اجرا شد و صحنه ها با محیط ها و انیمیشن ها جان گرفتند. ما از three.js به عنوان موتور سه بعدی استفاده می کنیم و به دلیل معماری به راحتی می توان به حالت قابل پخش رسید.

موقعیت ماوس بیشتر به کاربر راه دور ارسال می‌شود و یک نور سه بعدی در مورد جایی که مکان‌نما در آن لحظه است، اشاره می‌کند.