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 temel odak noktası, Chrome ve Firefox'ta içeriği daha fazla tarayıcı ve cihaz görüntüleyebildiğinden ve WebRTC özellikleriyle birlikte çalışarak WebGL'nin daha geniş bir alanda kullanılmasını sağlamak oldu. Bu yılki denemeyle üç hedefimiz vardı:
- Android için Chrome'da WebRTC ve WebGL kullanarak P2P oyun oynama
- 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 denememiz kolaylaştı. Gridle tabanlı bir kurulum kullanmak, yalnızca aynı veya komşu karolardaki nesnelerle çarpışma olup olmadığını kontrol etmeniz gerektiğinden oyunda iyi bir performans elde etmek için çarpışma algılamasına da yardımcı olur. 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 basit ve öğrenilmesi gereken çok fazla etkileşim içermemelidir. Orta Dünya haritasında, birden fazla oyuncunun eşler arası savaşta rekabet edebileceği oyun odaları olarak hizmet veren beş Savaş Alanı tanımlayarak başladık. Mobil ekranda odada bulunan birden fazla oyuncuyu göstermek ve kullanıcıların kime meydan okuyacağını seçmelerine izin vermek başlı başına bir sorundu. Etkileşimi ve sahneyi kolaylaştırmak için yalnızca meydan okuma ve kabul etme düğmesi kullanmaya karar verdik. Ayrıca, odada yalnızca etkinlikleri ve zirvenin mevcut kralını göstermeye karar verdik. Bu yön, eşleştirme tarafındaki birkaç sorunu da çözdü ve savaş için en iyi adaylarla eşleşmemizi sağladı. Önceki Chrome denememiz olan Cube Slam'da, oyunun sonucu gecikmeye bağlıysa çok oyunculu bir oyunda 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. İşinizi biraz kolaylaştırmak için bu oyunu sıraya dayalı 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 işler.
- Oyuncular arasında bağlantı kurmaya yardımcı olacak sunucular.
- Oyun odalarındaki tüm oyuncularla bağlantı kurmak ve iletişim kurmak için kullanılan AppEngine Channels API sinyallerini işlemek için bir API.
- İki oyuncu/emsal arasındaki durumun ve RTC mesajlaşmasını senkronize eden 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ırlandırmanın temel nedeni, yeni oyuncuların makul bir süre içinde skor tablosunda üst sıralara ulaşabilmelerini 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. Oyuncuları, odaları, skorları, oturumları ve oyundaki ilişkilerini saklamamız gerekiyor. 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'un arayüzüdür. NDB'yi kullanmaya başladığımızda ilk başta iyi sonuçlar elde ettik ancak kısa süre sonra bu aracı nasıl kullanmamız gerektiğiyle 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 doğrudan önbellekten yanıt verdikleri için varlıklarda 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 testlerini ekledikten sonra sorunu net bir şekilde görebildik ve sorgulardan uzaklaşarak ilişkileri memcache'de virgülle ayrılmış bir listede tuttuk. 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 sadece gökkuşağı ve tek boynuzlu atlardan oluşmaz ancak birkaç sınırlamaya sahiptir. Bunlardan en önemli olanlar 1 MB değer boyutu (bir savaş meydanıyla ilgili çok fazla oda olamaz) ve anahtar geçerlilik süresidir (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 korkutucuydu. Sunucuları korumak yerine deneyimi oluşturmaya odaklanmayı tercih ettiğimizden bu yola devam etmedik. Ö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 çok ilginç bir seçenek olabilir.
Sonunda Google Cloud SQL'i bulduk ve ilişkileri MySQL'e taşıdık. Çok çalıştık ama sonunda işe yaradı. Güncellemeler artık tamamen atomik ve testler hâlâ başarılı. Ayrıca eşleştirme ve puan takibi işlemlerini çok daha güvenilir hale getirdi.
Zaman içinde verilerin büyük bir kısmı 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 olaylar 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.
Sinyal hizmeti için kullanabileceğiniz çeşitli üçüncü taraf kitaplıklar vardır. Bu kitaplıklar, WebRTC kurulumunu da basitleştirir. Seçenekler arasında PeerJS, SimpleWebRTC ve PubNub WebRTC SDK'sı yer alı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 sunucunun binlerce eşzamanlı kullanıcıyı işleyebileceğinden emin olmamız gerekir. Kanal API'sinin 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 sağlam olduğuyla ilgili 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 bir kitaplık kullanmayı tercih etmediğimizden kendi kitaplığımızı oluşturmamız gerekiyordu. 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 Kanal API'si ü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şim bir 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 göre ö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. Player Management API'miz, oyun etkinlikleriyle ilgili bildirimleri için Channel API'yi kullanıyor.
Kanallar API'si ile ç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. İşleyiş şekliyle ilgili 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 olabildiğince dinamik hale getirmek için kullanıcı arabirimi uygulamasını varlık-bileşen-sistemi (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ışma sistemi ve yapay zeka kontrolündeki oyuncular için bir sistem eklendi. Projenin ortasında, kodun geri kalanını değiştirmeden 3D oluşturma sistemine geçebildik. Ağ 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. Hepsine ek olarak, hangi dönüşün gerçekleştirileceğine karar vermenin bir mantığı vardır. Oyuncu, pas/saldırı düğmelerine bastığında, önceki animasyona bakarken oyuna gelirse sıraya girdiğinde kuyruğa girer.
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 trol gibi düşman eklememiz gerektiğinde yapay zeka sistemi tekrar kullanışlı hale geldi (sadece test etmek için değil). 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ü. Örümceklerin sırası geldiğinde, sıra yöneticisi yapay zeka sisteminin uzak kullanıcıya gönderilen bir komut oluşturmasını sağlar. 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 tuval oluşturucunun olması idealdi. 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 kullanıyoruz. Mimari sayesinde oynanabilir bir duruma geçmek kolay oldu.
Farenin konumu, uzak kullanıcıya daha sık gönderilir ve imlecin o anda nerede olduğuna dair 3D ışıklarla hafif bir ipuçları verilir.