Hobbit Deneyimi 2014

Hobbit Deneyimine WebRTC oyunu ekleme

Demir Isaksson
Daniel Isaksson

“Hobbit: Beş Ordunun Savaşı” adlı yeni Hobbit filminin zamanı geldiğinde geçen seneki Chrome Denemesi olan Orta Dünya'da Bir Yolculuk'u yeni içeriklerle genişletmeye çalıştık. Bu sefer ana odak noktası, daha fazla tarayıcı ve cihaz içeriği görüntüleyebildiği ve Chrome ile Firefox'ta WebRTC özellikleriyle çalışabildiği için WebGL kullanımının kapsamının genişletilmesi oldu. Bu yılın denemesinde üç hedefimiz vardı:

  • Android için Chrome'da WebRTC ve WebGL kullanarak P2P oyun oynama
  • Dokunmatik girişe dayalı, oynanması kolay bir çok oyunculu oyun geliştirin
  • Google Cloud Platform'da barındırın

Oyunu tanımlama

Oyun mantığı, ızgara tabanlı bir düzende kuruluyor. Birlikler oyun tahtasında hareket ediyor. Böylece kuralları belirlerken oynanabilirliği kağıt üzerinde kolayca deneyebildik. Tek yapmanız gereken aynı veya komşu kutulardaki nesnelerle çarpışma olup olmadığını kontrol etmeniz gerektiği için ızgara tabanlı kurulum kullanarak oyundaki çarpışma algılama özelliği de iyi bir performans sağlayabilir. Yeni oyunda Orta Dünya'nın dört ana gücü olan İnsanlar, Cüceler, Elfler ve Orklar arasındaki bir savaşa odaklanmak istediğimizi daha en baştan biliyorduk. Ayrıca, bir Chrome Denemesinde oynanacak kadar basit olmalı ve öğrenilecek çok fazla etkileşimde bulunmamalıydı. Orta Dünya haritasında, birden fazla oyuncunun bire bir savaşta rekabet edebileceği oyun salonları olarak hizmet veren beş Savaş Meydanı tanımlayarak işe başladık. Mobil cihaz ekranında birden fazla oyuncu göstermek ve kullanıcıların kime meydan okuyacaklarını seçmelerine imkan tanımak başlı başına bir zorluktu. Etkileşimi ve ortamı kolaylaştırmak amacıyla, meydan okumayı kabul etmek için tek bir düğme koymaya karar verdik. Odayı yalnızca etkinlikleri ve tepenin şu anki kralının kim olduğunu göstermek için kullandık. Bu yön aynı zamanda eşleştirme tarafındaki birkaç sorunu da çözdü ve savaşta en iyi adayları eşleştirmemizi sağladı. Önceki Chrome deneyimimiz Cube Slam'de, çok oyunculu bir oyunda sonuç bu oyuna dayanıyorsa gecikme sorununu çözmenin çok fazla çalışma gerektirdiğini öğrendik. Sürekli rakibin durumunun nerede olacağına dair varsayımlarda bulunmanız gerekir. Rakibiniz siz olduğunuzu düşünüyor ve bunu farklı cihazlardaki animasyonlarla senkronize etmek zorundasınız. Bu makalede bu zorluklar daha ayrıntılı olarak açıklanmaktadır. İşi biraz kolaylaştırmak için bu oyunu sıraya dayalı hale getirdik.

Oyun mantığı, ızgara tabanlı bir düzende kuruluyor. Birlikler oyun tahtasında hareket ediyor. Böylece kuralları belirlerken oynanabilirliği kağıt üzerinde kolayca deneyebildik. Yalnızca aynı veya komşu kutulardaki nesnelerle çarpışma olup olmadığını kontrol etmeniz gerektiğinden, ızgara tabanlı kurulum kullanmak oyundaki çarpışma algılamaya da yardımcı olarak iyi bir performans sağlar.

Oyunun bölümleri

Bu çok oyunculu oyunu geliştirmek için oluşturmamız gereken birkaç temel parça var:

  • Sunucu tarafı oyuncu yönetimi API'si kullanıcıları, eşleştirmeyi, oturumları ve oyun istatistiklerini yönetir.
  • Oyuncular arasında bağlantı kurulmasına yardımcı olan sunucular.
  • Oyun odalarındaki tüm oyuncularla bağlantı ve iletişim kurmak için kullanılan AppEngine Channels API sinyallerini işleyen bir API.
  • İki oynatıcı/eşler arasında durum ve RTC mesajı senkronizasyonunu işleyen bir JavaScript Oyun motoru.
  • WebGL oyun görünümü.

Oyuncu yönetimi

Çok sayıda oyuncuyu desteklemek için her Savaş Meydanı'nda çok sayıda paralel oyun odası kullanıyoruz. Oyun odası başına oyuncu sayısını sınırlandırmanın temel nedeni, yeni oyuncuların makul bir süre içinde skor tablosunun en üstüne ulaşmasına imkan tanımaktır. Sınır, Channel API aracılığıyla gönderilen ve 32 KB sınırı olan oyun odasını açıklayan json nesnesinin boyutuyla da bağlantılıdır. Oyuncuları, odaları, skorları, seansları ve oyun içindeki ilişkilerini depolamamız gerekir. Bunu yapmak için öncelikle varlıklar için NDB'yi, ilişkileri ele almak için de sorgu arayüzünü kullandık. NDB, Google Cloud Datastore arayüzüdür. NDB kullanmak başlangıçta harikaydı, ancak kısa süre sonra bunu nasıl kullanmamız gerektiğiyle ilgili bir sorunla karşılaştık. Sorgu, veritabanının "taahhüt edilen" sürümünde çalıştırılmıştır (NDB Yazma işlemleri bu ayrıntılı makalede uzun süredir açıklanmaktadır) ve birkaç saniyelik gecikme olabilir. Ancak doğrudan önbellekten yanıt verdikleri için varlıklarda bu gecikme yaşanmadı. Birkaç örnek kodla açıklamak biraz daha kolay olabilir:

// 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,
    }

Birim testlerini ekledikten sonra sorunu net bir şekilde görebildik. Bunun yerine, ilişkileri memcache'ta virgülle ayrılmış bir listede tutmak için sorgulardan uzaklaştık. Bu bana umumi basitmişti ama işe yaradı ve AppEngine memcache'in mükemmel "karşılaştır ve ayarla" özelliğini kullanan, anahtarlar için işleme benzer bir sistemi olduğu için testler tekrar başarılı oldu.

Ne yazık ki memcache, tüm gökkuşağı ve tek boynuzlu atlardan oluşmaz ancak birkaç sınırlama içerir. Bunların en önemlileri 1 MB değer boyutudur (savaş meydanıyla ilgili çok fazla odaya sahip olamaz) ve anahtarların geçerlilik süresinin dolmasıdır veya dokümanlarda açıklandığı gibidir:

Yine de bir diğer harika anahtar/değer deposu olan Redis'i kullanmayı düşündük. Ancak o zamanlar ölçeklenebilir bir küme kurmak biraz göz korkutucuydu. Sunucuların bakımını yapmak yerine deneyimi geliştirmeye odaklanmayı tercih ettiğimiz için bu yolu tercih etmedik. Öte yandan Google Cloud Platform, kısa süre önce basit bir Click-to-deploy özelliğini kullanıma sundu. Seçeneklerden biri Redis Kümesi olduğundan bu çok ilginç bir seçenekti.

Son olarak Google Cloud SQL'i bulduk ve ilişkileri MySQL'e taşıdık. Çok fazla çalıştım ancak sonunda mükemmel çalıştı. Güncellemeler artık tamamen atomik hale geldi ve testler hala başarılı oldu. Ayrıca, eşleştirme ve puan korumayı uygulamayı çok daha güvenilir hale getirdi.

Zaman içinde verilerin daha fazlası NDB ve memcache'den SQL'e taşınmıştır. Ancak genel olarak oynatıcı, savaş alanı ve oda varlıkları hâlâ NDB'de saklanırken, bunların tümü arasındaki oturumlar ve ilişkiler SQL'de saklanır.

Ayrıca kimin oynadığını izlememiz ve oyuncuların beceri seviyesini ve deneyimini dikkate alan bir eşleştirme mekanizması kullanarak oyuncuları birbirleriyle eşleştirmemiz gerekiyordu. Eşleştirmeyi baz alan Glicko2 açık kaynak kitaplığını kullandık.

Bu çok oyunculu bir oyun olduğundan odadaki diğer oyuncuları "kim katıldı veya ayrıldı", "kim kazandı veya kaybetti" gibi etkinlikler ve kabul edilmesi gereken bir meydan okuma olup olmadığı hakkında bilgilendirmek istiyoruz. Bu sorunu çözmek için Player Management API'ye bildirim alma özelliğini ekledik.

WebRTC kurulumu

İki oyuncu bir savaş için eşleştirildiğinde, eşleştirilen iki üyenin birbirleriyle konuşmasını sağlamak ve akran bağlarını güçlendirmek için bir sinyal hizmeti kullanılıyor.

Sinyal hizmeti için kullanabileceğiniz ve WebRTC ayarlamayı basitleştiren çeşitli üçüncü taraf kitaplıkları vardır. Bazı seçenekler PeerJS, SimpleWebRTC ve PubNub WebRTC SDK'sıdır. PubNub, barındırılan bir sunucu çözümü kullanıyor. Biz de bu proje için Google Cloud Platform'da barındırmak istedik. Diğer iki kitaplık, Google Compute Engine'e yükleyebileceğimiz Node.js sunucularını kullanır, ancak aynı zamanda Channel API'nin yapabildiğini zaten bildiğimiz, binlerce eşzamanlı kullanıcıyı işleyebildiğinden da emin olmamız gerekir.

Bu durumda Google Cloud Platform'u kullanmanın temel avantajlarından biri ölçeklendirmedir. AppEngine projesi için gereken kaynakların ölçeklendirilmesi, Google Developers Console üzerinden kolayca gerçekleştirilebilir ve Channels API'yi kullanırken sinyal hizmetini ölçeklendirmek için fazladan işlem yapılmasına gerek yoktur.

Gecikme ve Kanallar API'sinin ne kadar sağlam olduğuna dair bazı endişeler vardı, ancak daha önce CubeSlam projesi için kullanıyorduk ve bu projedeki milyonlarca kullanıcıya işe yaradığı kanıtlandı ve bu yüzden tekrar kullanmaya karar verdik.

WebRTC konusunda yardımcı olması için bir üçüncü taraf kitaplığı kullanmayı seçmediğimizden kendi kitaplığımızı oluşturmamız gerekti. Neyse ki CubeSlam projesi için yaptığımız birçok işi yeniden kullanabildik. Her iki oyuncu da bir oturuma katıldığında, oturum "etkin" olarak ayarlanır. Böylece her iki oyuncu da Channel API üzerinden eşler arası bağlantıyı başlatmak için bu etkin oturum kimliğini kullanır. Bundan sonra iki oyuncu arasındaki tüm iletişim bir RTCDataChannel üzerinden gerçekleştirilecektir.

Bağlantının kurulmasına ve NAT'ler ve güvenlik duvarlarıyla başa çıkılmasına yardımcı olması için STUN ve TURN sunucularına da ihtiyacımız vardır. WebRTC in the real world: STUN, TURN, andSignaling başlıklı HTML5 Rocks makalesinde WebRTC'yi ayarlamayla ilgili ayrıntılı bilgi edinebilirsiniz.

Kullanılan TURN sunucularının sayısının da trafiğe bağlı olarak ölçeklenebilmesi gerekir. Bunun için Google Deployment Manager'ı test ettik. Kaynakları Google Compute Engine'de dinamik olarak dağıtmamıza ve bir şablon kullanarak TURN sunucularını yüklememize olanak tanır. Hala alfa sürümündedir, ancak amaçlarımız doğrultusunda kusursuz bir şekilde çalıştı. TURN sunucusu için STUN/TURN işlevinin çok hızlı, verimli ve güvenilir görünen bir uygulaması olan coturn'ü kullanıyoruz.

Channel API

Channel API, istemci tarafındaki oyun odasına ve oyun odasından tüm iletişimleri göndermek için kullanılır. Oyuncu Yönetimi API'miz oyun etkinlikleriyle ilgili bildirimleri için Channel API'yi kullanıyor.

Kanallar API'siyle çalışırken hızlandırılmış birkaç düşüş yaşandı. Örneğin, iletiler sıralı olmadan gelebileceği için bir nesnedeki tüm iletileri sarmamız ve sıralamamız gerekiyor. Aşağıda, bu sürecin nasıl işlediğine ilişkin bazı örnek kod verilmiştir:

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
    }
  }
}

Ayrıca sitenin farklı API'lerini modüler ve sitenin barındırmasından ayrı tutmak istedik ve GAE'de yerleşik olarak bulunan modülleri kullanarak işe koyulduk. Geliştiricilerde her şeyin çalışmasını sağladıktan sonra, ne yazık ki Channel API'nin üretimde modüllerle hiç çalışmadığını fark ettik. Bunun yerine, ayrı GAE örnekleri kullanmaya başladık ve bizi iframe postMessage köprüsü kullanmaya zorlayan CORS sorunlarıyla karşılaştık.

Oyun motoru

Oyun motorunu mümkün olduğunca dinamik hale getirmek için kullanıcı arabirimi uygulamasını entity-component-system (ECS) yaklaşımını kullanarak oluşturduk. Geliştirmeye başladığımızda şemalar ve işlevsel spesifikasyonlar henüz belirlenmemişti. Bu nedenle, geliştirme süreci ilerledikçe yeni özellikler ve mantık ekleyebilmek çok yararlı oldu. Örneğin, ilk prototip varlıkları bir ızgarada görüntülemek için basit bir tuval oluşturma sistemi kullandı. Birkaç yineleme sonra, çarpışma sistemi ve yapay zeka kontrollü oyuncular için bir sistem eklendi. Projenin ortasında kodun geri kalanını değiştirmeden 3d oluşturucu sistemine geçebildik. Ağ iletişimi parçaları çalışır durumda olduğunda yapay zeka sistemi, uzaktan komutları kullanacak şekilde değiştirilebilir.

Bu yüzden çok oyunculu oyunun temel mantığı, aksiyon komutu yapılandırmasını DataChannels üzerinden diğer eşe göndermek ve simülasyonun yapay zeka oyuncusuymuş gibi davranmasına izin vermektir. Bunların yanı sıra, oyuncu pas/saldırma düğmelerine basarsa hangi dönüşü yapacağına karar vermek gerekir. Oyuncu hâlâ önceki animasyona bakarken sırada gelirse sıra komutlarını verir vb.

Sırayı değiştiren sadece iki kullanıcı olsaydı, her iki kullanıcı da sırayla sırayı rakibe geçirme sorumluluğunu paylaşabilirdi ama işin içinde üçüncü bir oyuncu da var. Örümcek ve trol gibi düşmanlar eklememiz gerektiğinde yapay zeka sistemi yalnızca test için değil, yeniden pratik bir hale geldi. Sıraya dayalı akışa sığması için iki taraftan da tam olarak aynı şekilde üretilmesi ve uygulanması gerekiyordu. Bu sorun, bir meslektamın dönüş sistemini kontrol etmesine ve mevcut durumu uzak eşe göndermesine izin vererek çözüldü. Daha sonra sıra örümceklere geldiğinde, dönüş yöneticisi ai-sistemin uzaktaki kullanıcıya gönderilecek bir komut oluşturmasına izin verir. Oyun motoru sadece komutlara ve Varlık Kimliği'ne göre hareket ettiğinden, oyun her iki tarafta da aynı şekilde simüle edilir. Tüm birimlerde, kolay otomatik test yapılmasını sağlayan ai bileşen de bulunabilir.

Geliştirme sürecinin başlarında, oyun mantığına odaklanırken daha basit bir tuval oluşturucunun kullanılması idealti. Ancak asıl eğlence, 3D sürümün uygulanması ve sahnelerin ortamlar ve animasyonlarla hayata geçirilmesiyle başladı. 3D motor olarak three.js'yi kullanıyoruz ve mimari nedeniyle, oynatılabilir duruma gelmesi kolaydı.

Farenin konumu uzaktaki kullanıcıya daha sık gönderilir ve 3D ışığı, imlecin o anda bulunduğu yerle ilgili hafif ipuçları verir.