Menulis Mesin Petualangan Teks Multiplayer Di Node.js: Desain Server Mesin Game (Bagian 2)
Diterbitkan: 2022-03-10Setelah beberapa pertimbangan yang cermat dan implementasi modul yang sebenarnya, beberapa definisi yang saya buat selama fase desain harus diubah. Ini harus menjadi pemandangan yang akrab bagi siapa saja yang pernah bekerja dengan klien yang bersemangat yang memimpikan produk yang ideal tetapi perlu dikendalikan oleh tim pengembangan.
Setelah fitur diterapkan dan diuji, tim Anda akan mulai memperhatikan bahwa beberapa karakteristik mungkin berbeda dari rencana awal, dan itu tidak masalah. Cukup beri tahu, sesuaikan, dan lanjutkan. Jadi, tanpa basa-basi lagi, izinkan saya menjelaskan terlebih dahulu apa yang berubah dari rencana awal.
Bagian Lain Dari Seri Ini
- Bagian 1: Pendahuluan
- Bagian 3: Membuat Klien Terminal
- Bagian 4: Menambahkan Obrolan Ke Game Kami
Mekanika Pertempuran
Ini mungkin perubahan terbesar dari rencana awal. Saya tahu saya mengatakan saya akan pergi dengan implementasi D&D-esque di mana setiap PC dan NPC yang terlibat akan mendapatkan nilai inisiatif dan setelah itu, kami akan menjalankan pertarungan berbasis giliran. Itu ide yang bagus, tetapi menerapkannya pada layanan berbasis REST agak rumit karena Anda tidak dapat memulai komunikasi dari sisi server, atau mempertahankan status di antara panggilan.
Jadi sebagai gantinya, saya akan memanfaatkan mekanisme REST yang disederhanakan dan menggunakannya untuk menyederhanakan mekanisme pertempuran kami. Versi yang diterapkan akan berbasis pemain, bukan berbasis partai, dan akan memungkinkan pemain untuk menyerang NPC (Karakter Non-Pemain). Jika serangan mereka berhasil, NPC akan terbunuh atau mereka akan menyerang balik dengan merusak atau membunuh pemain.
Berhasil atau tidaknya suatu serangan akan ditentukan oleh jenis senjata yang digunakan dan kelemahan yang mungkin dimiliki NPC. Jadi pada dasarnya, jika monster yang kamu coba bunuh lemah terhadap senjatamu, dia akan mati. Jika tidak, itu tidak akan terpengaruh dan — kemungkinan besar — sangat marah.
Pemicu
Jika Anda memperhatikan definisi game JSON dari artikel saya sebelumnya, Anda mungkin telah memperhatikan definisi pemicu yang ditemukan pada item adegan. Yang tertentu melibatkan pembaruan status game ( statusUpdate
). Selama implementasi, saya menyadari bahwa itu berfungsi sebagai sakelar memberikan kebebasan terbatas. Anda lihat, dalam cara penerapannya (dari sudut pandang idiomatik), Anda dapat mengatur status tetapi menghapus pengaturan itu bukan pilihan. Jadi sebagai gantinya, saya mengganti efek pemicu ini dengan dua yang baru: addStatus
dan removeStatus
. Ini akan memungkinkan Anda untuk menentukan dengan tepat kapan efek ini dapat terjadi — jika memang ada. Saya merasa ini jauh lebih mudah untuk dipahami dan dipikirkan.
Ini berarti pemicunya sekarang terlihat seperti ini:
"triggers": [ { "action": "pickup", "effect":{ "addStatus": "has light", "target": "game" } }, { "action": "drop", "effect": { "removeStatus": "has light", "target": "game" } } ]
Saat mengambil item, kami menyiapkan status, dan saat menjatuhkannya, kami menghapusnya. Dengan cara ini, memiliki beberapa indikator status level game sepenuhnya mungkin dan mudah dikelola.
Pelaksanaan
Dengan tidak adanya pembaruan tersebut, kita dapat mulai membahas implementasi yang sebenarnya. Dari sudut pandang arsitektur, tidak ada yang berubah; kami masih membangun REST API yang akan berisi logika mesin game utama.
Tumpukan Teknologi
Untuk proyek khusus ini, modul yang akan saya gunakan adalah sebagai berikut:
Modul | Keterangan |
---|---|
Express.js | Jelas, saya akan menggunakan Express sebagai basis untuk keseluruhan mesin. |
Winston | Segala sesuatu yang berhubungan dengan logging akan ditangani oleh Winston. |
konfigurasi | Setiap variabel konstan dan tergantung lingkungan akan ditangani oleh modul config.js, yang sangat menyederhanakan tugas mengaksesnya. |
Luwak | Ini akan menjadi ORM kami. Saya akan memodelkan semua sumber daya menggunakan Model Mongoose dan menggunakannya untuk berinteraksi langsung dengan database. |
uuid | Kita perlu membuat beberapa ID unik — modul ini akan membantu kita dengan tugas itu. |
Adapun teknologi lain yang digunakan selain dari Node.js, kami memiliki MongoDB dan Redis . Saya suka menggunakan Mongo karena kurangnya skema yang diperlukan. Fakta sederhana itu memungkinkan saya untuk memikirkan kode dan format data saya, tanpa harus khawatir tentang memperbarui struktur tabel saya, migrasi skema, atau tipe data yang bertentangan.
Mengenai Redis, saya cenderung menggunakannya sebagai sistem pendukung sebanyak yang saya bisa dalam proyek saya dan kasus ini tidak berbeda. Saya akan menggunakan Redis untuk segala sesuatu yang dapat dianggap sebagai informasi yang mudah berubah, seperti nomor anggota partai, permintaan perintah, dan jenis data lain yang cukup kecil dan cukup mudah berubah sehingga tidak layak disimpan secara permanen.
Saya juga akan menggunakan fitur kedaluwarsa kunci Redis untuk mengelola beberapa aspek aliran secara otomatis (lebih lanjut tentang ini segera).
Definisi API
Sebelum beralih ke interaksi klien-server dan definisi aliran data, saya ingin membahas titik akhir yang ditentukan untuk API ini. Mereka tidak banyak, kebanyakan kita harus mematuhi fitur utama yang dijelaskan di Bagian 1:
Fitur | Keterangan |
---|---|
Bergabunglah dengan permainan | Seorang pemain akan dapat bergabung dengan permainan dengan menentukan ID permainan. |
Buat permainan baru | Seorang pemain juga dapat membuat instance game baru. Mesin harus mengembalikan ID, sehingga orang lain dapat menggunakannya untuk bergabung. |
Adegan kembali | Fitur ini harus mengembalikan adegan saat ini di mana pesta berada. Pada dasarnya, ini akan mengembalikan deskripsi, dengan semua informasi terkait (kemungkinan tindakan, objek di dalamnya, dll.). |
Berinteraksi dengan adegan | Ini akan menjadi salah satu yang paling kompleks, karena akan mengambil perintah dari klien dan melakukan tindakan itu — hal-hal seperti memindahkan, mendorong, mengambil, melihat, membaca, dan lain-lain. |
Periksa inventaris | Meskipun ini adalah cara untuk berinteraksi dengan permainan, itu tidak secara langsung berhubungan dengan adegan. Jadi, memeriksa inventaris untuk setiap pemain akan dianggap sebagai tindakan yang berbeda. |
Daftarkan aplikasi klien | Tindakan di atas memerlukan klien yang valid untuk menjalankannya. Titik akhir ini akan memverifikasi aplikasi klien dan mengembalikan ID Klien yang akan digunakan untuk tujuan otentikasi pada permintaan berikutnya. |
Daftar di atas diterjemahkan ke dalam daftar titik akhir berikut:
Kata kerja | Titik akhir | Keterangan |
---|---|---|
POS | /clients | Aplikasi klien akan membutuhkan untuk mendapatkan kunci ID Klien menggunakan titik akhir ini. |
POS | /games | Instance game baru dibuat menggunakan titik akhir ini oleh aplikasi klien. |
POS | /games/:id | Setelah game dibuat, titik akhir ini akan memungkinkan anggota party untuk bergabung dan mulai bermain. |
DAPATKAN | /games/:id/:playername | Titik akhir ini akan mengembalikan status permainan saat ini untuk pemain tertentu. |
POS | /games/:id/:playername/commands | Terakhir, dengan titik akhir ini, aplikasi klien akan dapat mengirimkan perintah (dengan kata lain, titik akhir ini akan digunakan untuk bermain). |
Biarkan saya masuk ke sedikit lebih detail tentang beberapa konsep yang saya jelaskan dalam daftar sebelumnya.
Aplikasi Klien
Aplikasi klien perlu mendaftar ke sistem untuk mulai menggunakannya. Semua titik akhir (kecuali yang pertama dalam daftar) diamankan dan akan memerlukan kunci aplikasi yang valid untuk dikirim bersama permintaan. Untuk mendapatkan kunci itu, aplikasi klien hanya perlu memintanya. Setelah disediakan, mereka akan bertahan selama digunakan, atau akan kedaluwarsa setelah sebulan tidak digunakan. Perilaku ini dikendalikan dengan menyimpan kunci di Redis dan menyetel TTL selama satu bulan.
Contoh Game
Membuat game baru pada dasarnya berarti membuat instance baru dari game tertentu. Instance baru ini akan berisi salinan semua adegan dan kontennya. Modifikasi apa pun yang dilakukan pada game hanya akan memengaruhi party. Dengan cara ini, banyak kelompok dapat memainkan permainan yang sama dengan cara mereka masing-masing.
Status Game Pemain
Ini mirip dengan yang sebelumnya, tetapi unik untuk setiap pemain. Sementara instance game memegang status game untuk seluruh party, status game pemain memegang status saat ini untuk satu pemain tertentu. Terutama, ini menyimpan inventaris, posisi, adegan saat ini, dan HP (poin kesehatan).
Perintah Pemain
Setelah semuanya diatur dan aplikasi klien telah terdaftar dan bergabung dengan permainan, itu dapat mulai mengirim perintah. Perintah yang diimplementasikan pada engine versi ini meliputi: move
, look
, pickup
dan attack
.
- Perintah
move
akan memungkinkan Anda untuk melintasi peta. Anda akan dapat menentukan arah yang ingin Anda tuju dan mesin akan memberi tahu Anda hasilnya. Jika Anda melihat sekilas Bagian 1, Anda dapat melihat pendekatan yang saya ambil untuk menangani peta. (Singkatnya, peta direpresentasikan sebagai grafik, di mana setiap node mewakili ruangan atau pemandangan dan hanya terhubung ke node lain yang mewakili ruangan yang berdekatan.)
Jarak antar node juga hadir dalam representasi dan ditambah dengan kecepatan standar yang dimiliki pemain; pergi dari kamar ke kamar mungkin tidak sesederhana menyatakan perintah Anda, tetapi Anda juga harus melintasi jarak. Dalam praktiknya, ini berarti bahwa berpindah dari satu ruangan ke ruangan lain mungkin memerlukan beberapa perintah pindah). Aspek menarik lainnya dari perintah ini berasal dari fakta bahwa mesin ini dimaksudkan untuk mendukung pesta multipemain, dan pesta tidak dapat dibagi (setidaknya saat ini).
Oleh karena itu, solusi untuk ini mirip dengan sistem pemungutan suara: setiap anggota partai akan mengirimkan permintaan perintah pindah kapan pun mereka mau. Setelah lebih dari setengah dari mereka melakukannya, arah yang paling banyak diminta akan digunakan. -
look
sangat berbeda dari bergerak. Ini memungkinkan pemain untuk menentukan arah, item, atau NPC yang ingin mereka periksa. Logika kunci di balik perintah ini, menjadi pertimbangan saat Anda memikirkan deskripsi yang bergantung pada status.
Misalnya, katakanlah Anda memasuki ruangan baru, tetapi benar-benar gelap (Anda tidak melihat apa-apa), dan Anda bergerak maju sambil mengabaikannya. Beberapa kamar kemudian, Anda mengambil obor yang menyala dari dinding. Jadi sekarang Anda bisa kembali dan memeriksa kembali ruangan gelap itu. Karena Anda telah mengambil obor, Anda sekarang dapat melihat di dalamnya, dan dapat berinteraksi dengan item dan NPC yang Anda temukan di sana.
Ini dicapai dengan mempertahankan set atribut status khusus pemain dan seluruh game dan memungkinkan pembuat game untuk menentukan beberapa deskripsi untuk elemen yang bergantung pada status kami dalam file JSON. Setiap deskripsi kemudian dilengkapi dengan teks default dan satu set teks bersyarat, tergantung pada status saat ini. Yang terakhir adalah opsional; satu-satunya yang wajib adalah nilai default.
Selain itu, perintah ini memiliki versi singkat untuklook at room: look around
; itu karena pemain akan sering mencoba memeriksa ruangan, jadi memberikan perintah short-hand (atau alias) yang lebih mudah diketik sangat masuk akal. - Perintah
pickup
memainkan peran yang sangat penting untuk gameplay. Perintah ini menangani penambahan item ke inventaris pemain atau tangan mereka (jika gratis). Untuk memahami di mana setiap item dimaksudkan untuk disimpan, definisi mereka memiliki properti "tujuan" yang menentukan apakah itu dimaksudkan untuk inventaris atau tangan pemain. Apa pun yang berhasil diambil dari tempat kejadian kemudian dihapus darinya, memperbarui versi game instance game. - Perintah
use
akan memungkinkan Anda untuk mempengaruhi lingkungan menggunakan item dalam inventaris Anda. Misalnya, mengambil kunci di sebuah ruangan akan memungkinkan Anda menggunakannya untuk membuka pintu yang terkunci di ruangan lain. - Ada perintah khusus, yang tidak terkait dengan gameplay, melainkan perintah pembantu yang dimaksudkan untuk mendapatkan informasi tertentu, seperti ID game saat ini atau nama pemain. Perintah ini disebut get , dan para pemain dapat menggunakannya untuk menanyakan mesin permainan. Misalnya: dapatkan gameid .
- Akhirnya, perintah terakhir yang diterapkan untuk versi mesin ini adalah perintah
attack
. Saya sudah membahas yang ini; pada dasarnya, Anda harus menentukan target dan senjata yang digunakan untuk menyerangnya. Dengan begitu sistem akan dapat memeriksa kelemahan target dan menentukan output serangan Anda.
Interaksi Klien-Mesin
Untuk memahami cara menggunakan titik akhir yang tercantum di atas, izinkan saya menunjukkan kepada Anda bagaimana setiap calon klien dapat berinteraksi dengan API baru kami.
Melangkah | Keterangan |
---|---|
Daftarkan klien | Hal pertama yang pertama, aplikasi klien perlu meminta kunci API untuk dapat mengakses semua titik akhir lainnya. Untuk mendapatkan kunci itu, ia perlu mendaftar di platform kami. Satu-satunya parameter yang harus disediakan adalah nama aplikasi, itu saja. |
Buat permainan | Setelah kunci API diperoleh, hal pertama yang harus dilakukan (dengan asumsi ini adalah interaksi baru) adalah membuat instance game baru. Pikirkan seperti ini: file JSON yang saya buat di posting terakhir saya berisi definisi game, tetapi kita perlu membuat instance itu hanya untuk Anda dan pesta Anda (pikirkan kelas dan objek, kesepakatan yang sama). Anda dapat melakukan dengan contoh itu apa pun yang Anda inginkan, dan itu tidak akan mempengaruhi pihak lain. |
Bergabunglah dengan permainan | Setelah membuat game, Anda akan mendapatkan ID game kembali dari mesin. Anda kemudian dapat menggunakan ID game tersebut untuk bergabung dengan instance menggunakan nama pengguna unik Anda. Kecuali Anda bergabung dengan game, Anda tidak bisa bermain, karena bergabung dengan game juga akan membuat instance status game untuk Anda sendiri. Ini akan menjadi tempat inventaris Anda, posisi Anda, dan statistik dasar Anda disimpan sehubungan dengan permainan yang Anda mainkan. Anda berpotensi memainkan beberapa game secara bersamaan, dan di masing-masing game memiliki status independen. |
Kirim perintah | Dengan kata lain: bermain game. Langkah terakhir adalah mulai mengirim perintah. Jumlah perintah yang tersedia sudah tercakup, dan dapat dengan mudah diperpanjang (lebih lanjut tentang ini sebentar lagi). Setiap kali Anda mengirim perintah, game akan mengembalikan status game baru agar klien Anda memperbarui tampilan Anda. |
Ayo Kotor Tangan Kita
Saya telah membahas desain sebanyak yang saya bisa, dengan harapan informasi itu akan membantu Anda memahami bagian berikut, jadi mari masuk ke mur dan baut mesin game.
Catatan : Saya tidak akan menunjukkan kode lengkapnya di artikel ini karena cukup besar dan tidak semuanya menarik. Sebagai gantinya, saya akan menunjukkan bagian yang lebih relevan dan menautkan ke repositori lengkap jika Anda menginginkan detail lebih lanjut.
File Utama
Hal pertama yang pertama: ini adalah proyek Express dan kode boilerplate berbasisnya dibuat menggunakan generator Express sendiri, jadi file app.js seharusnya sudah tidak asing lagi bagi Anda. Saya hanya ingin membahas dua perubahan yang ingin saya lakukan pada kode itu untuk menyederhanakan pekerjaan saya.
Pertama, saya menambahkan cuplikan berikut untuk mengotomatiskan penyertaan file rute baru:
const requireDir = require("require-dir") const routes = requireDir("./routes") //... Object.keys(routes).forEach( (file) => { let cnt = routes[file] app.use('/' + file, cnt) })
Ini sebenarnya cukup sederhana, tetapi menghilangkan kebutuhan untuk secara manual meminta setiap file rute yang Anda buat di masa mendatang. Omong-omong, require-dir
adalah modul sederhana yang menangani kebutuhan otomatis setiap file di dalam folder. Itu dia.
Perubahan lain yang ingin saya lakukan adalah sedikit mengubah penangan kesalahan saya. Saya harus benar-benar mulai menggunakan sesuatu yang lebih kuat, tetapi untuk kebutuhan yang ada, saya merasa ini menyelesaikan pekerjaan:
// 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); });
Kode di atas menangani berbagai jenis pesan kesalahan yang mungkin harus kita tangani — baik objek penuh, objek kesalahan aktual yang dilemparkan oleh Javascript, atau pesan kesalahan sederhana tanpa konteks lain. Kode ini akan mengambil semuanya dan memformatnya menjadi format standar.
Menangani Perintah
Ini adalah salah satu aspek mesin yang harus mudah diperluas. Dalam proyek seperti ini, sangat masuk akal untuk mengasumsikan bahwa perintah baru akan muncul di masa mendatang. Jika ada sesuatu yang ingin Anda hindari, maka itu mungkin menghindari membuat perubahan pada kode dasar ketika mencoba menambahkan sesuatu yang baru tiga atau empat bulan ke depan.
Tidak ada jumlah komentar kode yang akan membuat tugas memodifikasi kode yang belum Anda sentuh (atau bahkan pikirkan) dalam beberapa bulan menjadi mudah, jadi prioritasnya adalah menghindari sebanyak mungkin perubahan. Beruntung bagi kami, ada beberapa pola yang bisa kami terapkan untuk mengatasi ini. Secara khusus, saya menggunakan campuran pola Command dan Factory.
Saya pada dasarnya merangkum perilaku setiap perintah di dalam satu kelas yang mewarisi dari kelas BaseCommand
yang berisi kode generik untuk semua perintah. Pada saat yang sama, saya menambahkan modul CommandParser
yang mengambil string yang dikirim oleh klien dan mengembalikan perintah yang sebenarnya untuk dieksekusi.
Pengurai sangat sederhana karena semua perintah yang diimplementasikan sekarang memiliki perintah sebenarnya untuk kata pertama mereka (yaitu "bergerak ke utara", "mengambil pisau", dan seterusnya) ini masalah sederhana untuk memisahkan string dan mendapatkan bagian pertama:
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 } } }
Catatan : Saya menggunakan modul require-dir
sekali lagi untuk menyederhanakan penyertaan kelas perintah yang ada dan yang baru. Saya cukup menambahkannya ke folder dan seluruh sistem dapat mengambilnya dan menggunakannya.
Dengan itu, ada banyak cara untuk meningkatkannya; misalnya, dengan dapat menambahkan dukungan sinonim untuk perintah kami akan menjadi fitur yang hebat (sehingga mengatakan "bergerak ke utara", "pergi ke utara" atau bahkan "berjalan ke utara" akan berarti sama). Itu adalah sesuatu yang kita bisa memusatkan di kelas ini dan mempengaruhi semua perintah pada waktu yang sama.
Saya tidak akan merinci perintah apa pun karena, sekali lagi, terlalu banyak kode untuk ditampilkan di sini, tetapi Anda dapat melihat dalam kode rute berikut bagaimana saya berhasil menggeneralisasi penanganan perintah yang ada (dan yang akan datang):
/** 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) }) })
Semua perintah hanya memerlukan metode run
— yang lainnya bersifat ekstra dan dimaksudkan untuk penggunaan internal.
Saya mendorong Anda untuk pergi dan meninjau seluruh kode sumber (bahkan mengunduhnya dan memainkannya jika Anda mau!). Di bagian selanjutnya dari seri ini, saya akan menunjukkan kepada Anda implementasi dan interaksi klien sebenarnya dari API ini.
Pikiran Penutup
Saya mungkin belum membahas banyak kode saya di sini, tetapi saya masih berharap artikel ini bermanfaat untuk menunjukkan kepada Anda bagaimana saya menangani proyek — bahkan setelah fase desain awal. Saya merasa banyak orang mencoba untuk memulai pengkodean sebagai tanggapan pertama mereka terhadap ide baru dan kadang-kadang dapat membuat pengembang putus asa karena tidak ada rencana nyata yang ditetapkan atau tujuan apa pun untuk dicapai — selain menyiapkan produk akhir ( dan itu adalah pencapaian yang terlalu besar untuk diatasi sejak hari ke-1. Jadi sekali lagi, harapan saya dengan artikel ini adalah untuk berbagi cara berbeda untuk bekerja sendiri (atau sebagai bagian dari kelompok kecil) dalam proyek besar.
Saya harap Anda menikmati membaca! Silakan tinggalkan komentar di bawah dengan segala jenis saran atau rekomendasi, saya ingin membaca pendapat Anda dan jika Anda ingin mulai menguji API dengan kode sisi klien Anda sendiri.
Sampai jumpa di yang berikutnya!
Bagian Lain Dari Seri Ini
- Bagian 1: Pendahuluan
- Bagian 3: Membuat Klien Terminal
- Bagian 4: Menambahkan Obrolan Ke Game Kami