Hobbit deneyimine WebRTC oyun deneyimi ekleme
Yeni Hobbit filmi "Hobbit: Beş Ordunun Savaşı"nın gösterime girmesiyle birlikte, geçen yılki Chrome denemesi Orta Dünya'da Bir Yolculuk'u yeni içeriklerle genişlettik. Bu seferki ana odak, daha fazla tarayıcı ve cihazın içeriği görüntüleyebilmesi için WebGL'nin kullanımını yaygınlaştırmak ve Chrome ile Firefox'taki WebRTC özellikleriyle çalışmaktı. Bu yılki denemeyle üç hedefimiz vardı:
- Android için Chrome'da WebRTC ve WebGL kullanan eşler arası oyun deneyimi
- Kolay oynanabilen ve dokunmatik girişe dayalı çok oyunculu bir oyun oluşturma
- Google Cloud Platform'da barındırma
Oyunu tanımlama
Oyun mantığı, bir oyun tahtasında hareket eden askerlerin yer aldığı, ızgara tabanlı bir düzene dayanır. Bu sayede, kuralları tanımlarken oyunun oynanışını kağıt üzerinde deneyebilirdik. Ayrıca, yalnızca aynı veya komşu karolardaki nesnelerle çarpışma olup olmadığını kontrol etmeniz gerektiğinden, ızgara tabanlı bir kurulum kullanmak oyunda çarpışma algılamasına yardımcı olarak iyi bir performans elde etmenizi sağlar. Yeni oyunda Orta Dünya'nın dört ana gücü olan insanlar, cüceler, elfler ve orklar arasındaki savaşa odaklanmak istediğimizi başından beri biliyorduk. Ayrıca, Chrome denemesinde oynanabilecek kadar rahat ve öğrenilmesi gereken çok fazla etkileşim içermemelidir. Orta Dünya haritasında, birden fazla oyuncunun eşle oynayabileceği oyun odaları olarak hizmet veren beş Battlegrounds'ı tanımlayarak başladık. Mobil ekranda odada bulunan birden fazla oyuncuyu göstermek ve kullanıcıların kime meydan okuyacağını seçmesine izin vermek başlı başına bir sorundu. Etkileşimi ve sahneyi kolaylaştırmak için yalnızca meydan okuma ve kabul etmek için bir düğme kullanmaya ve odayı yalnızca etkinlikleri ve zirvenin mevcut kralını göstermek için kullanmaya karar verdik. Bu yaklaşım, eşleştirme tarafındaki birkaç sorunu da çözdü ve en iyi savaş adaylarını eşleştirmemize olanak tanıdı. Önceki Chrome denememiz olan Cube Slam'da, çok oyunculu bir oyunda gecikmenin oyunun sonucuna etki etmesi durumunda gecikmeyi yönetmenin çok fazla iş gerektirdiğini öğrendik. Rakip oyuncunun nerede olacağını, nerede olduğunuzu ne düşündüğünü sürekli olarak tahmin etmeniz ve bunu farklı cihazlardaki animasyonlarla senkronize etmeniz gerekir. Bu zorluklar bu makalede daha ayrıntılı olarak açıklanmaktadır. Oyunu biraz daha kolaylaştırmak için sıra tabanlı hale getirdik.
Oyun mantığı, bir oyun tahtasında hareket eden askerlerin yer aldığı, ızgara tabanlı bir düzene dayanır. Bu sayede, kuralları tanımlarken oyunun oynanışını kağıt üzerinde denememiz kolaylaştı. Ayrıca, yalnızca aynı veya komşu karolardaki nesnelerle çarpışma olup olmadığını kontrol etmeniz gerektiğinden, ızgaraya dayalı bir kurulum kullanmak, oyunda iyi bir performans elde etmek için çarpışma algılamasına da yardımcı olur.
Oyunun bölümleri
Bu çok oyunculu oyunu oluşturmak için birkaç önemli parçayı oluşturmamız gerekiyordu:
- 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ı kurmak ve iletişim kurmak için kullanılan AppEngine Channels API sinyallerini işlemek üzere tasarlanmış bir API.
- İki oyuncu/eşler arasında durum senkronizasyonunu ve RTC mesajlaşmasını yöneten bir JavaScript oyun motoru.
- WebGL oyun görünümü.
Oyuncu yönetimi
Çok sayıda oyuncuyu desteklemek için her Savaş Alanı için birçok paralel oyun odası kullanırız. Oyun odası başına oyuncu sayısını sınırlamanın temel nedeni, yeni oyuncuların makul bir sürede skor tablosunun üst sıralarına ulaşmasını sağlamaktı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. Oyunda oyuncuları, odaları, skorları, oturumları ve bunların ilişkilerini saklamamız gerekir. Bunu yapmak için önce varlıklar için NDB'yi, ilişkileri işlemek için ise sorgu arayüzünü kullandık. NDB, Google Cloud Datastore'a arayüzdür. NDB'yi kullanmaya başladığımızda ilk başta iyi sonuçlar elde ettik ancak kısa süre sonra NDB'yi kullanma şeklimizle ilgili bir sorunla karşılaştık. Sorgu, veritabanının "taahhüt edilmiş" sürümünde çalıştırıldı (NDB Yazıları, bu ayrıntılı makalede ayrıntılı olarak açıklanmaktadır). Bu işlem birkaç saniye gecikmeye neden olabilir. Ancak varlıklar doğrudan önbellekten yanıt verdiği için bu gecikme yaşanmadı. Bunu ö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 testleri ekledikten sonra sorunu net bir şekilde görebildik ve ilişkileri memcache'te virgülle ayrılmış bir listede tutmak için sorgulardan uzaklaştık. Bu biraz hile gibi geldi ancak işe yaradı ve AppEngine memcache'te mükemmel "karşılaştır ve ayarla" özelliğini kullanan anahtarlar için işlem benzeri bir sistem var. Bu nedenle testler tekrar geçti.
Maalesef memcache her zaman mükemmel değildir ve birkaç sınır vardır. Bunlardan en dikkat çekenleri 1 MB değer boyutu (bir savaş alanıyla ilgili çok fazla oda olamaz) ve anahtar süresinin sona ermesidir. Belgelerde açıklandığı gibi:
Redis adlı başka bir mükemmel anahtar/değer çifti deposunu kullanmayı da düşündük. Ancak o zamanlar ölçeklenebilir bir küme oluşturmak biraz göz korkutucu bir işti ve sunucuları yönetmektense deneyimi oluşturmaya odaklanmayı tercih ettiğimiz için bu yolu izlemedik. Öte yandan Google Cloud Platform kısa süre önce basit bir Tıkla ve dağıt özelliğini kullanıma sundu. Bu özellikteki seçeneklerden biri Redis kümesi olduğundan bu özellik çok ilgi çekici olabilir.
Sonunda Google Cloud SQL'i bulduk ve ilişkileri MySQL'e taşıdık. Çok fazla çalışma gerektirdi ancak sonunda mükemmel bir sonuç elde ettik. Güncellemeler artık tamamen atomik ve testler yine geçiyor. Ayrıca eşleştirme ve puan takibi işlemlerini çok daha güvenilir hale getirdi.
Zaman içinde verilerin çoğu yavaş yavaş NDB ve memcache'ten SQL'e taşındı ancak genel olarak oyuncu, savaş alanı ve oda varlıkları hâlâ NDB'de depolanırken oturumlar ve bunların arasındaki ilişkiler SQL'de depolanır.
Ayrıca, kimlerin kimle oynadığını takip etmemiz ve oyuncuların beceri düzeyini ve deneyimini dikkate alan bir eşleştirme mekanizması kullanarak oyuncuları birbirleriyle eşleştirmemiz gerekiyordu. Eşleştirmeyi, açık kaynaklı Glicko2 kitaplığını temel alarak yaptık.
Bu çok oyunculu bir oyun olduğundan, "kim girdi veya çıktı", "kim kazandı veya kaybetti" gibi etkinlikler ve kabul edilecek bir meydan okuma olup olmadığı hakkında odadaki diğer oyuncuları bilgilendirmek isteriz. Bu sorunu gidermek için Player Management API'ye bildirim alma özelliğini ekledik.
WebRTC'yi ayarlama
İki oyuncu bir maç için eşlendiğinde, eşlenen iki oyuncunun birbirleriyle konuşmasını sağlamak ve eşler arası bağlantı başlatmaya yardımcı olmak için bir sinyal hizmeti kullanılır.
İşaretleme hizmeti için kullanabileceğiniz ve WebRTC'yi ayarlamayı da kolaylaştıran çeşitli üçüncü taraf kitaplıkları vardır. PeerJS, SimpleWebRTC ve PubNub WebRTC SDK'sı gibi seçenekler vardır. PubNub, barındırılan bir sunucu çözümü kullanır. Bu proje için Google Cloud Platform'da barındırmak istedik. Diğer iki kitaplık, Google Compute Engine'a yükleyebileceğimiz node.js sunucuları kullanır. Ancak bu sunucuların binlerce eşzamanlı kullanıcıyı işleyebileceğinden emin olmamız gerekir. Channel API'nin bunu yapabileceğini zaten biliyorduk.
Bu durumda Google Cloud Platform'u kullanmanın en önemli avantajlarından biri ölçeklenebilirliktir. AppEngine projesi için gereken kaynakların ölçeklendirilmesi Google Developers Console üzerinden kolayca yapılabilir ve Channels API kullanılırken sinyal hizmetini ölçeklendirmek için ek çalışma gerekmez.
Gecikme ve Channels API'nin ne kadar güçlü olduğu konusunda bazı endişelerimiz vardı ancak daha önce CubeSlam projesi için kullandığımız ve bu projedeki milyonlarca kullanıcı için çalıştığını kanıtladığımız API'yi tekrar kullanmaya karar verdik.
WebRTC'ye yardımcı olması için üçüncü taraf kitaplığı kullanmayı tercih etmediğimizden kendi kitaplığımızı oluşturmak zorunda kaldık. Neyse ki CubeSlam projesi için yaptığımız çalışmaların çoğunu yeniden kullanabildik. Her iki oyuncu da bir oturuma katıldığında oturum "etkin" olarak ayarlanır. Ardından her iki oyuncu da Channel API üzerinden eşler arası bağlantıyı başlatmak için bu etkin oturum kimliğini kullanır. Ardından, iki oyuncu arasındaki tüm iletişimler RTCDataChannel üzerinden gerçekleştirilir.
Bağlantıyı kurmak ve NAT'ler ile güvenlik duvarlarıyla başa çıkmak için STUN ve TURN sunucularına da ihtiyacımız var. WebRTC'yi ayarlama hakkında daha ayrıntılı bilgi edinmek için HTML5 Rocks'taki WebRTC in the real world: STUN, TURN, and signaling (Gerçek dünyada WebRTC: STUN, TURN ve sinyal) makalesini inceleyin.
Kullanılan TURN sunucularının sayısının da trafiğe bağlı olarak ölçeklendirilebilmesi gerekir. Bu sorunu çözmek için Google Dağıtım Yöneticisi'ni test ettik. Bu sayede, Google Compute Engine'de kaynakları dinamik olarak dağıtabilir ve şablon kullanarak TURN sunucuları kurabiliriz. Hâlâ alfa sürümünde olsa da amacımıza uygun olarak kusursuz bir şekilde çalıştı. TURN sunucusu için STUN/TURN'in çok hızlı, verimli ve görünüşte güvenilir bir uygulaması olan coturn'u kullanırız.
Channel API
Channel API, istemci tarafında oyun odasına gelen ve oyun odasından giden 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 birkaç sorunla karşılaştık. Örneğin, iletiler sırasız gelebileceğinden tüm iletileri bir nesneye sarmalayıp sıralamamız gerekiyordu. Bu işlemin nasıl çalıştığına dair bazı örnek kodlar aşağıda 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ırma hizmetinden ayrı tutmak istedik ve GAE'de yerleşik modülleri kullanmaya başladık. Maalesef tüm bunları geliştirme aşamasında çalıştırdıktan sonra, Kanal API'sinin üretim aşamasında modüllerle çalışmadığını fark ettik. Bunun yerine ayrı GAE örnekleri kullanmaya başladık ve bir iframe postMessage köprüsü kullanmaya zorlayan CORS sorunlarına rastladık.
Oyun motoru
Oyun motorunu mümkün olduğunca dinamik hale getirmek için ön uç uygulamasını varlık-bileşen-sistem (ECS) yaklaşımını kullanarak oluşturduk. Geliştirmeye başladığımızda taslak ve işlevsel spesifikasyon belirlenmemişti. Bu nedenle, geliştirme ilerledikçe özellik ve mantık ekleyebilmemiz çok faydalı oldu. Örneğin, ilk prototip, varlıkları bir ızgara şeklinde görüntülemek için basit bir tuval oluşturma sistemi kullanıyordu. Birkaç iterasyondan sonra çarpışmalar ve yapay zeka kontrollü oyuncular için bir sistem eklendi. Projenin ortasında, kodun geri kalanını değiştirmeden 3D oluşturma sistemine geçebildik. Ağ bağlantısı parçaları çalışırken yapay zeka sistemi, uzaktan komutlar kullanacak şekilde değiştirilebilir.
Dolayısıyla çok oyunculu oyunun temel mantığı, işlem komutunun yapılandırmasını DataChannels aracılığıyla diğer eşe göndermek ve simülasyonun yapay zeka oyuncusu gibi davranmasına izin vermektir. Bununla birlikte, hangi turun olduğuna karar veren bir mantık, oyuncunun pas/saldırı düğmelerine basması, oyuncu hâlâ önceki animasyona bakarken gelen komutları sıraya ekleme vb. işlemleri vardır.
Sadece iki kullanıcının sıra değiştirdiği durumlarda, her iki kullanıcı da sırayı bittiğinde rakibe verme sorumluluğunu paylaşabilir. Ancak burada üçüncü bir oyuncu var. Örümcek ve troll gibi düşmanlar eklememiz gerektiğinde yapay zeka sistemi (yalnızca test için değil) yine işe yaradı. Bunları sıra tabanlı akışa sığdırmak için her iki tarafta da tam olarak aynı şekilde oluşturulup yürütülmesi gerekiyordu. Bu sorun, bir eşin sıra sistemini kontrol etmesine ve mevcut durumu uzaktaki eşe göndermesine izin verilerek çözüldü. Ardından, örümceklerin sırası geldiğinde sıra yöneticisi, yapay zeka sisteminin uzaktaki kullanıcıya gönderilecek bir komut oluşturmasına izin verir. Oyun motoru yalnızca komutlar ve varlık kimlikleri üzerinde işlem yaptığından oyun her iki tarafta da aynı şekilde simüle edilir. Tüm birimler, kolay otomatik test yapmanızı sağlayan yapay zeka bileşenine de sahip olabilir.
Geliştirmenin başında oyun mantığına odaklanırken daha basit bir kanvas oluşturma aracı kullanmak en uygun seçenekti. Ancak asıl eğlence, 3D sürümü uygulandığında ve sahneler ortamlar ve animasyonlarla canlandığında başladı. 3D motor olarak three.js'i kullanıyoruz. Mimari sayesinde oynanabilir bir duruma geçmek kolay oldu.
Fare konumu uzaktaki kullanıcıya daha sık gönderilir ve imlecin o anda nerede olduğuna dair 3D ışıklı ince ipuçları gösterilir.