Node.js'de Çok Oyunculu Metin Macera Motoru Yazma: Oyun Motoru Sunucusu Tasarımı (2. Kısım)
Yayınlanan: 2022-03-10Modülün dikkatli bir şekilde değerlendirilmesinden ve fiili olarak uygulanmasından sonra, tasarım aşamasında yaptığım bazı tanımların değiştirilmesi gerekiyordu. Bu, ideal bir ürün hayal eden, ancak geliştirme ekibi tarafından kısıtlanması gereken hevesli bir müşteriyle çalışmış olan herkes için tanıdık bir sahne olmalıdır.
Özellikler uygulandıktan ve test edildikten sonra ekibiniz bazı özelliklerin orijinal plandan farklı olabileceğini fark etmeye başlayacak ve bunda bir sorun yok. Basitçe haber verin, ayarlayın ve devam edin. O halde lafı daha fazla uzatmadan önce orijinal planda nelerin değiştiğini açıklamama izin verin.
Bu Serinin Diğer Parçaları
- Bölüm 1: Giriş
- Bölüm 3: Terminal İstemcisi Oluşturma
- 4. Bölüm: Oyunumuza Sohbet Eklemek
Savaş Mekanikleri
Bu muhtemelen orijinal plandaki en büyük değişiklik. Dahil olan her PC ve NPC'nin bir inisiyatif değeri alacağı ve bundan sonra sıra tabanlı bir savaş yapacağımız bir D&D-esque uygulamasına gideceğimi söylediğimi biliyorum. Güzel bir fikirdi, ancak bunu REST tabanlı bir hizmette uygulamak biraz karmaşık çünkü iletişimi sunucu tarafından başlatamazsınız veya aramalar arasında durumu koruyamazsınız.
Bunun yerine, REST'in basitleştirilmiş mekaniğinden yararlanacağım ve bunu savaş mekaniğimizi basitleştirmek için kullanacağım. Uygulanan sürüm, parti tabanlı yerine oyuncu tabanlı olacak ve oyuncuların NPC'lere (Oyuncu Olmayan Karakterler) saldırmasına izin verecek. Saldırıları başarılı olursa, NPC'ler öldürülecek veya oyuncuya zarar vererek veya oyuncuyu öldürerek geri saldıracaklar.
Bir saldırının başarılı olup olmadığı, kullanılan silahın türüne ve bir NPC'nin sahip olabileceği zayıflıklara göre belirlenir. Yani temelde, öldürmeye çalıştığınız canavar silahınıza karşı zayıfsa ölür. Aksi takdirde, etkilenmeyecek ve - büyük olasılıkla - çok kızgın olacak.
tetikleyiciler
Bir önceki yazımdan JSON oyun tanımına dikkat ettiyseniz, tetikleyicinin sahne öğelerinde bulunan tanımını fark etmiş olabilirsiniz. Belirli bir oyun durumunu güncellemeyi içeriyordu ( statusUpdate
). Uygulama sırasında, bir geçiş olarak çalışmasının sınırlı özgürlük sağladığını fark ettim. Görüyorsunuz, uygulanma biçiminde (deyimsel bir bakış açısıyla), bir durum belirleyebildiniz, ancak ayarı kaldırmak bir seçenek değildi. Bunun yerine, bu tetikleme efektini iki yenisiyle değiştirdim: addStatus
ve removeStatus
. Bunlar, bu etkilerin tam olarak ne zaman gerçekleşebileceğini tanımlamanıza olanak tanır - eğer varsa. Bunu anlamanın ve akıl yürütmenin çok daha kolay olduğunu hissediyorum.
Bu, tetikleyicilerin şu şekilde göründüğü anlamına gelir:
"triggers": [ { "action": "pickup", "effect":{ "addStatus": "has light", "target": "game" } }, { "action": "drop", "effect": { "removeStatus": "has light", "target": "game" } } ]
Öğeyi alırken bir durum oluşturuyoruz ve düşürürken onu kaldırıyoruz. Bu şekilde, birden fazla oyun düzeyinde durum göstergesine sahip olmak tamamen mümkün ve yönetimi kolaydır.
Hayata geçirme
Bu güncellemeler ortadan kalktığında, gerçek uygulamayı ele almaya başlayabiliriz. Mimari açıdan bakıldığında hiçbir şey değişmedi; hala ana oyun motorunun mantığını içerecek bir REST API oluşturuyoruz.
Teknoloji Yığını
Bu özel proje için kullanacağım modüller şunlardır:
Modül | Tanım |
---|---|
Express.js | Açıkçası, tüm motorun temeli olarak Express'i kullanacağım. |
Winston | Günlüğe kaydetme ile ilgili her şey Winston tarafından ele alınacaktır. |
yapılandırma | Her sabit ve ortama bağlı değişken, bunlara erişme görevini büyük ölçüde basitleştiren config.js modülü tarafından işlenecektir. |
firavun faresi | Bu bizim ORM'imiz olacak. Tüm kaynakları Mongoose Modellerini kullanarak modelleyeceğim ve bunu doğrudan veritabanıyla etkileşim kurmak için kullanacağım. |
uuid | Bazı benzersiz kimlikler oluşturmamız gerekecek - bu modül bu görevde bize yardımcı olacak. |
Node.js dışında kullanılan diğer teknolojilere gelince, MongoDB ve Redis'imiz var. Gerekli şema eksikliğinden dolayı Mongo kullanmayı seviyorum. Bu basit gerçek, tablolarımın yapısını, şema geçişlerini veya çakışan veri türlerini güncelleme konusunda endişelenmeme gerek kalmadan kodum ve veri biçimleri hakkında düşünmeme izin veriyor.
Redis'i projelerimde elimden geldiğince destek sistemi olarak kullanmaya özen gösteriyorum ve bu durum da farklı değil. Parti üye numaraları, komut istekleri ve kalıcı depolamayı hak etmeyecek kadar küçük ve uçucu olan diğer veri türleri gibi geçici bilgiler olarak kabul edilebilecek her şey için Redis kullanacağım.
Ayrıca, akışın bazı yönlerini otomatik olarak yönetmek için Redis'in anahtar sona erme özelliğini kullanacağım (bundan biraz daha fazlası).
API Tanımı
İstemci-sunucu etkileşimi ve veri akışı tanımlarına geçmeden önce bu API için tanımlanan uç noktaların üzerinden geçmek istiyorum. O kadar çok değiller, çoğunlukla Bölüm 1'de açıklanan ana özelliklere uymamız gerekiyor:
Özellik | Tanım |
---|---|
Bir oyuna katıl | Bir oyuncu, oyunun kimliğini belirterek bir oyuna katılabilir. |
Yeni bir oyun oluştur | Bir oyuncu ayrıca yeni bir oyun örneği oluşturabilir. Başkalarının katılmak için kullanabilmesi için motor bir kimlik döndürmelidir. |
dönüş sahnesi | Bu özellik, partinin bulunduğu mevcut sahneyi döndürmelidir. Temel olarak, ilgili tüm bilgilerle (olası eylemler, içindeki nesneler vb.) açıklamayı döndürür. |
sahne ile etkileşim | Bu en karmaşık olanlardan biri olacak, çünkü istemciden bir komut alacak ve bu eylemi gerçekleştirecek - hareket etme, itme, alma, bakma, okuma, bunlardan sadece birkaçı. |
Envanteri kontrol et | Bu, oyunla etkileşim kurmanın bir yolu olsa da, doğrudan sahne ile ilgili değildir. Bu nedenle, her oyuncu için envanteri kontrol etmek farklı bir işlem olarak kabul edilecektir. |
İstemci uygulamasını kaydedin | Yukarıdaki eylemler, bunları yürütmek için geçerli bir istemci gerektirir. Bu uç nokta, istemci uygulamasını doğrulayacak ve sonraki isteklerde kimlik doğrulama amacıyla kullanılacak bir İstemci Kimliği döndürecektir. |
Yukarıdaki liste, aşağıdaki uç noktalar listesine çevrilir:
Fiil | uç nokta | Tanım |
---|---|---|
İLETİ | /clients | İstemci uygulamalarının bu uç noktayı kullanarak bir İstemci Kimliği anahtarı alması gerekir. |
İLETİ | /games | İstemci uygulamaları tarafından bu uç nokta kullanılarak yeni oyun örnekleri oluşturulur. |
İLETİ | /games/:id | Oyun oluşturulduktan sonra, bu uç nokta, parti üyelerinin oyuna katılmasını ve oynamaya başlamasını sağlar. |
ELDE ETMEK | /games/:id/:playername | Bu uç nokta, belirli bir oyuncu için mevcut oyun durumunu döndürür. |
İLETİ | /games/:id/:playername/commands | Son olarak, bu uç nokta ile istemci uygulama komutlar gönderebilecektir (başka bir deyişle, bu uç nokta oynamak için kullanılacaktır). |
Bir önceki listede anlattığım bazı kavramlar hakkında biraz daha detaya gireyim.
İstemci Uygulamaları
İstemci uygulamalarının kullanmaya başlamak için sisteme kaydolması gerekir. Tüm uç noktalar (listedeki ilki hariç) güvenlidir ve istekle birlikte gönderilecek geçerli bir uygulama anahtarı gerektirir. Bu anahtarı elde etmek için istemci uygulamalarının bir tane istemesi yeterlidir. Sağlandıktan sonra, kullanıldıkları sürece dayanacak veya bir ay kullanılmadığında sona erecektir. Bu davranış, anahtarı Redis'te depolayarak ve ona bir aylık uzun bir TTL ayarlayarak kontrol edilir.
Oyun Örneği
Yeni bir oyun oluşturmak, temel olarak belirli bir oyunun yeni bir örneğini oluşturmak anlamına gelir. Bu yeni örnek, tüm sahnelerin ve içeriklerinin bir kopyasını içerecek. Oyunda yapılacak herhangi bir değişiklik sadece partiyi etkileyecektir. Bu şekilde, birçok grup aynı oyunu kendi bireysel yollarıyla oynayabilir.
Oyuncunun Oyun Durumu
Bu, bir öncekine benzer, ancak her oyuncu için benzersizdir. Oyun örneği, tüm grup için oyun durumunu tutarken, oyuncunun oyun durumu, belirli bir oyuncu için mevcut durumu tutar. Esas olarak bu, envanteri, pozisyonu, mevcut sahneyi ve HP'yi (sağlık puanları) tutar.
Oyuncu Komutları
Her şey ayarlandıktan ve istemci uygulaması bir oyuna kaydolup katıldıktan sonra komut göndermeye başlayabilir. Motorun bu versiyonunda uygulanan komutlar şunları içerir: move
, look
, pickup
ve attack
.
-
move
komutu, haritayı geçmenize izin verecektir. Gitmek istediğiniz yönü belirleyebileceksiniz ve motor sonucu size bildirecek. Bölüm 1'e kısa bir göz atarsanız, haritaları işlemek için benim izlediğim yaklaşımı görebilirsiniz. (Kısacası harita, her düğümün bir oda veya sahneyi temsil ettiği ve yalnızca bitişik odaları temsil eden diğer düğümlere bağlı olduğu bir grafik olarak temsil edilir.)
Düğümler arasındaki mesafe de temsilde mevcuttur ve bir oyuncunun sahip olduğu standart hız ile birleştirilir; odadan odaya gitmek, komutunuzu belirtmek kadar basit olmayabilir, ancak mesafeyi de geçmeniz gerekecek. Pratikte bu, bir odadan diğerine geçmenin birkaç hareket komutu gerektirebileceği anlamına gelir). Bu komutun diğer ilginç yönü, bu motorun çok oyunculu partileri desteklemesi ve partinin bölünememesi gerçeğinden geliyor (en azından şu anda değil).
Bu nedenle, bunun çözümü oylama sistemine benzer: her parti üyesi istediği zaman bir hareket komutu isteği gönderir. Yarısından fazlası bunu yaptıktan sonra, en çok istenen yön kullanılacaktır. -
look
hareketten oldukça farklıdır. Oyuncunun incelemek istediği yönü, öğeyi veya NPC'yi belirlemesini sağlar. Bu komutun arkasındaki temel mantık, duruma bağlı açıklamalar hakkında düşündüğünüzde dikkate alınır.
Örneğin, diyelim ki yeni bir odaya girdiniz ama tamamen karanlık (hiçbir şey görmüyorsunuz) ve onu görmezden gelerek ilerliyorsunuz. Birkaç oda sonra, duvardan yanan bir meşale alıyorsunuz. Şimdi geri dönüp o karanlık odayı yeniden inceleyebilirsiniz. Meşaleyi aldığınıza göre, artık içini görebilir ve orada bulduğunuz herhangi bir eşya ve NPC ile etkileşime girebilirsiniz.
Bu, oyun genelinde ve oyuncuya özel bir durum öznitelikleri kümesini koruyarak ve oyun yaratıcısının JSON dosyasındaki duruma bağlı öğelerimiz için birkaç açıklama belirlemesine izin vererek elde edilir. Daha sonra her açıklama, mevcut duruma bağlı olarak bir varsayılan metin ve bir dizi koşullu metinle donatılır. İkincisi isteğe bağlıdır; zorunlu olan tek varsayılan değerdir.
Ek olarak, bu komutunlook at room: look around
; bunun nedeni, oyuncuların bir odayı çok sık incelemeye çalışacak olmalarıdır, bu nedenle yazması daha kolay olan bir kısa el (veya takma ad) komutu sağlamak çok mantıklıdır. -
pickup
komutu oyun için çok önemli bir rol oynar. Bu komut, öğelerin oyuncuların envanterine veya ellerine (ücretsizlerse) eklenmesiyle ilgilenir. Her bir öğenin nerede saklanacağını anlamak için, tanımlarının envantere mi yoksa oyuncunun ellerine mi yönelik olduğunu belirten bir “hedef” özelliği vardır. Sahneden başarılı bir şekilde alınan her şey daha sonra oyun örneğinin oyunun versiyonu güncellenerek sahneden kaldırılır. -
use
komutu, envanterinizdeki öğeleri kullanarak ortamı etkilemenize izin verecektir. Örneğin, bir odada bir anahtar almak, başka bir odada kilitli bir kapıyı açmak için kullanmanıza izin verecektir. - Oyunla ilgili olmayan özel bir komut vardır, bunun yerine mevcut oyun kimliği veya oyuncunun adı gibi belirli bilgileri elde etmeyi amaçlayan bir yardımcı komut vardır. Bu komuta get adı verilir ve oyuncular bunu oyun motorunu sorgulamak için kullanabilirler. Örneğin: gameid alın .
- Son olarak, motorun bu sürümü için uygulanan son komut,
attack
komutudur. Bunu zaten ele aldım; temel olarak, hedefinizi ve ona saldırdığınız silahı belirtmeniz gerekecek. Bu şekilde sistem, hedefin zayıf yönlerini kontrol edebilecek ve saldırınızın sonucunu belirleyebilecektir.
İstemci-Motor Etkileşimi
Yukarıda listelenen uç noktaların nasıl kullanılacağını anlamak için, herhangi bir istemci adayının yeni API'miz ile nasıl etkileşime girebileceğini size göstermeme izin verin.
Adım | Tanım |
---|---|
Müşteriyi kaydet | Her şeyden önce, istemci uygulamasının diğer tüm uç noktalara erişebilmesi için bir API anahtarı istemesi gerekir. Bu anahtarı alabilmek için platformumuza kaydolması gerekiyor. Sağlanacak tek parametre uygulamanın adıdır, hepsi bu. |
oyun oluştur | API anahtarı alındıktan sonra yapılacak ilk şey (bunun yepyeni bir etkileşim olduğunu varsayarak) yepyeni bir oyun örneği oluşturmaktır. Şöyle düşünün: Son yazımda oluşturduğum JSON dosyası oyunun tanımını içeriyor, ancak bunun sadece sizin ve partiniz için bir örneğini oluşturmamız gerekiyor (sınıfları ve nesneleri düşünün, aynı anlaşma). Bu örnekle istediğinizi yapabilirsiniz ve diğer tarafları etkilemeyecektir. |
Oyuna Katıl | Oyunu oluşturduktan sonra, motordan bir oyun kimliği alacaksınız. Ardından, benzersiz kullanıcı adınızı kullanarak örneğe katılmak için bu oyun kimliğini kullanabilirsiniz. Oyuna katılmadığınız sürece oynayamazsınız, çünkü oyuna katılmak aynı zamanda sadece sizin için bir oyun durumu örneği oluşturacaktır. Bu, oynadığınız oyunla ilgili envanterinizin, pozisyonunuzun ve temel istatistiklerinizin kaydedildiği yerdir. Potansiyel olarak aynı anda birkaç oyun oynuyor olabilirsiniz ve her birinde bağımsız durumlar olabilir. |
Komutları gönder | Başka bir deyişle: oyunu oynayın. Son adım, komutları göndermeye başlamaktır. Kullanılabilir komutların miktarı zaten kapsanmıştı ve kolayca genişletilebilir (bu konuda birazdan daha fazlası). Her komut gönderdiğinizde oyun, müşterinizin görüşünüzü buna göre güncellemesi için yeni oyun durumunu döndürür. |
Ellerimizi Kirletelim
Bu bilgilerin sonraki kısmı anlamanıza yardımcı olacağını umarak elimden geldiğince tasarımın üzerinden geçtim, o yüzden oyun motorunun somunlarına ve cıvatalarına geçelim.
Not : Bu yazıda size kodun tamamını göstermeyeceğim çünkü oldukça büyük ve hepsi ilgi çekici değil. Bunun yerine, daha fazla ayrıntı istemeniz durumunda daha alakalı bölümleri göstereceğim ve tüm depoya bağlantı vereceğim.
Ana Dosya
Her şeyden önce: Bu bir Express projesidir ve temel standart kod, Express'in kendi oluşturucusu kullanılarak oluşturulmuştur, bu nedenle app.js dosyası size aşina olmalıdır. İşimi basitleştirmek için bu kod üzerinde yapmayı sevdiğim iki ince ayarın üzerinden geçmek istiyorum.
İlk olarak, yeni rota dosyalarının eklenmesini otomatikleştirmek için aşağıdaki parçacığı ekliyorum:
const requireDir = require("require-dir") const routes = requireDir("./routes") //... Object.keys(routes).forEach( (file) => { let cnt = routes[file] app.use('/' + file, cnt) })
Gerçekten oldukça basit, ancak gelecekte oluşturacağınız her rota dosyasını manuel olarak gerektirme ihtiyacını ortadan kaldırıyor. Bu arada, require-dir
, bir klasör içindeki her dosyanın otomatik olarak istenmesiyle ilgilenen basit bir modüldür. Bu kadar.
Yapmayı sevdiğim diğer değişiklik, hata işleyicimi biraz değiştirmek. Gerçekten daha sağlam bir şey kullanmaya başlamalıyım, ancak eldeki ihtiyaçlar için bunun işi bitirdiğini hissediyorum:
// error handler app.use(function(err, req, res, next) { // render the error page if(typeof err === "string") { err = { status: 500, message: err } } res.status(err.status || 500); let errorObj = { error: true, msg: err.message, errCode: err.status || 500 } if(err.trace) { errorObj.trace = err.trace } res.json(errorObj); });
Yukarıdaki kod, uğraşmamız gerekebilecek farklı türde hata mesajlarıyla ilgilenir - tam nesneler, Javascript tarafından atılan gerçek hata nesneleri veya başka bir bağlam içermeyen basit hata mesajları. Bu kod hepsini alacak ve standart bir biçimde biçimlendirecektir.
İşleme Komutları
Bu, motorun genişletilmesi kolay olması gereken yönlerinden bir diğeridir. Bunun gibi bir projede, gelecekte yeni komutların ortaya çıkacağını varsaymak tamamen mantıklıdır. Kaçınmak istediğiniz bir şey varsa, bu muhtemelen gelecekte üç veya dört ay sonra yeni bir şey eklemeye çalışırken temel kodda değişiklik yapmaktan kaçınmak olacaktır.
Hiçbir kod yorumu, birkaç ay içinde dokunmadığınız (hatta düşünmediğiniz) kodu değiştirme görevini kolaylaştırmaz, bu nedenle öncelik, mümkün olduğunca çok değişiklikten kaçınmaktır. Şansımıza, bunu çözmek için uygulayabileceğimiz birkaç model var. Özellikle Komut ve Fabrika kalıplarının bir karışımını kullandım.
Temel olarak, her komutun davranışını, genel kodu tüm komutlara içeren bir BaseCommand
sınıfından miras alan tek bir sınıf içine yerleştirdim. Aynı zamanda, istemci tarafından gönderilen dizeyi alan ve yürütülecek asıl komutu döndüren bir CommandParser
modülü ekledim.
Ayrıştırıcı çok basittir, çünkü uygulanan tüm komutlar artık ilk sözcükleriyle ilgili gerçek komuta sahiptir (yani, "kuzeye hareket et", "bıçağı al", vb.) bu, dizeyi bölmek ve ilk kısmı almak basit bir meseledir:
const requireDir = require("require-dir") const validCommands = requireDir('./commands') class CommandParser { constructor(command) { this.command = command } normalizeAction(strAct) { strAct = strAct.toLowerCase().split(" ")[0] return strAct } verifyCommand() { if(!this.command) return false if(!this.command.action) return false if(!this.command.context) return false let action = this.normalizeAction(this.command.action) if(validCommands[action]) { return validCommands[action] } return false } parse() { let validCommand = this.verifyCommand() if(validCommand) { let cmdObj = new validCommand(this.command) return cmdObj } else { return false } } }
Not : Mevcut ve yeni komut sınıflarının dahil edilmesini basitleştirmek için bir kez daha require-dir
modülünü kullanıyorum. Ben sadece onu klasöre ekliyorum ve tüm sistem onu alıp kullanabiliyor.
Bununla birlikte, bunun iyileştirilebileceği birçok yol vardır; örneğin, komutlarımıza eş anlamlı desteği ekleyebilmek harika bir özellik olacaktır (bu nedenle “kuzeye git”, “kuzeye git” veya hatta “kuzeye yürü” demek aynı anlama gelir). Bu, bu sınıfta merkezileştirebileceğimiz ve tüm komutları aynı anda etkileyebileceğimiz bir şey.
Komutların hiçbiriyle ilgili ayrıntılara girmeyeceğim, çünkü yine, bu burada gösterilemeyecek kadar fazla koddur, ancak aşağıdaki rota kodunda mevcut (ve gelecekteki) komutların bu şekilde ele alınmasını nasıl genelleştirdiğimi görebilirsiniz:
/** Interaction with a particular scene */ router.post('/:id/:playername/:scene', function(req, res, next) { let command = req.body command.context = { gameId: req.params.id, playername: req.params.playername, } let parser = new CommandParser(command) let commandObj = parser.parse() //return the command instance if(!commandObj) return next({ //error handling status: 400, errorCode: config.get("errorCodes.invalidCommand"), message: "Unknown command" }) commandObj.run((err, result) => { //execute the command if(err) return next(err) res.json(result) }) })
Tüm komutlar yalnızca run
yöntemini gerektirir - diğer her şey ekstradır ve dahili kullanım içindir.
Gidip tüm kaynak kodunu gözden geçirmenizi tavsiye ederim (hatta isterseniz indirin ve onunla oynayın!). Bu dizinin sonraki bölümünde, size bu API'nin gerçek istemci uygulamasını ve etkileşimini göstereceğim.
Kapanış Düşünceleri
Kodumun çoğunu burada ele almamış olabilirim, ancak yine de makalenin, ilk tasarım aşamasından sonra bile, projeleri nasıl ele aldığımı göstermeye yardımcı olduğunu umuyorum. Pek çok insanın yeni bir fikre ilk tepkileri olarak kodlamaya başlamaya çalıştığını ve nihai ürünün hazır olması dışında gerçek bir plan veya ulaşılması gereken herhangi bir hedef olmadığı için bazen bir geliştiricinin cesaretini kırabileceğini hissediyorum ( ve bu, 1. günden itibaren üstesinden gelinemeyecek kadar büyük bir dönüm noktasıdır. Yine, bu makalelerle ilgili umudum, büyük projelerde tek başına (veya küçük bir grubun parçası olarak) çalışmanın farklı bir yolunu paylaşmaktır.
Umarım okumaktan keyif almışsınızdır! Lütfen herhangi bir öneri veya öneri için aşağıya bir yorum bırakmaktan çekinmeyin, ne düşündüğünüzü okumak isterim ve API'yi kendi istemci tarafı kodunuzla test etmeye başlamak istiyorsanız.
Bir sonrakinde görüşmek üzere!
Bu Serinin Diğer Parçaları
- Bölüm 1: Giriş
- Bölüm 3: Terminal İstemcisi Oluşturma
- 4. Bölüm: Oyunumuza Sohbet Eklemek