Menulis Mesin Petualangan Teks Multiplayer Di Node.js: Membuat Klien Terminal (Bagian 3)

Diterbitkan: 2022-03-10
Ringkasan cepat Bagian ketiga dari seri ini akan fokus pada penambahan klien berbasis teks untuk mesin game yang dibuat di bagian 2. Fernando Doglio menjelaskan desain arsitektur dasar, pemilihan alat, dan sorotan kode dengan menunjukkan cara membuat teks- UI berbasis dengan bantuan Node.js.

Saya pertama kali menunjukkan cara mendefinisikan proyek seperti ini, dan memberi Anda dasar-dasar arsitektur serta mekanisme di balik mesin permainan. Kemudian, saya menunjukkan kepada Anda implementasi dasar mesin — REST API dasar yang memungkinkan Anda melintasi dunia yang ditentukan JSON.

Hari ini, saya akan menunjukkan cara membuat klien teks jadul untuk API kita dengan menggunakan Node.js.

Bagian Lain Dari Seri Ini

  • Bagian 1: Pendahuluan
  • Bagian 2: Desain Server Mesin Game
  • Bagian 4: Menambahkan Obrolan Ke Game Kami

Meninjau Desain Asli

Ketika saya pertama kali mengusulkan gambar rangka dasar untuk UI, saya mengusulkan empat bagian di layar:

(Pratinjau besar)

Meskipun secara teori terlihat benar, saya melewatkan fakta bahwa beralih antara mengirim perintah game dan pesan teks akan merepotkan, jadi alih-alih meminta pemain kami beralih secara manual, kami akan meminta parser perintah kami memastikan ia mampu membedakan apakah kami sedang mencoba untuk berkomunikasi dengan permainan atau teman-teman kita.

Jadi, alih-alih memiliki empat bagian di layar kita, sekarang kita memiliki tiga:

(Pratinjau besar)

Itu adalah tangkapan layar aktual dari klien game terakhir. Anda dapat melihat layar permainan di sebelah kiri, dan obrolan di sebelah kanan, dengan satu kotak masukan umum di bagian bawah. Modul yang kami gunakan memungkinkan kami untuk menyesuaikan warna dan beberapa efek dasar. Anda akan dapat mengkloning kode ini dari Github dan melakukan apa yang Anda inginkan dengan tampilan dan nuansa.

Satu peringatan: Meskipun tangkapan layar di atas menunjukkan obrolan yang berfungsi sebagai bagian dari aplikasi, kami akan membuat artikel ini tetap fokus pada penyiapan proyek dan mendefinisikan kerangka kerja di mana kami dapat membuat aplikasi berbasis teks-UI dinamis. Kami akan fokus untuk menambahkan dukungan obrolan pada bab berikutnya dan terakhir dari seri ini.

Lebih banyak setelah melompat! Lanjutkan membaca di bawah ini

Alat yang Kami Butuhkan

Meskipun ada banyak perpustakaan di luar sana yang memungkinkan kita membuat alat CLI dengan Node.js, menambahkan UI berbasis teks adalah hal yang sangat berbeda untuk dijinakkan. Khususnya, saya hanya dapat menemukan satu perpustakaan (sangat lengkap, ingatlah) yang memungkinkan saya melakukan apa yang saya inginkan: Diberkati.

Pustaka ini sangat kuat dan menyediakan banyak fitur yang tidak akan kami gunakan untuk proyek ini (seperti casting shadows, drag&drop, dan lain-lain). Ini pada dasarnya mengimplementasikan ulang seluruh pustaka ncurses (pustaka C yang memungkinkan pengembang membuat UI berbasis teks) yang tidak memiliki ikatan Node.js, dan ia melakukannya langsung di JavaScript; jadi, jika harus, kami dapat memeriksa kode internalnya dengan baik (sesuatu yang tidak akan saya rekomendasikan kecuali Anda benar-benar harus melakukannya).

Meskipun dokumentasi untuk Blessed cukup luas, ini terutama terdiri dari detail individu tentang setiap metode yang disediakan (sebagai lawan dari tutorial yang menjelaskan bagaimana sebenarnya menggunakan metode tersebut bersama-sama) dan kurangnya contoh di mana-mana, jadi mungkin sulit untuk menggali ke dalamnya. jika Anda harus memahami cara kerja metode tertentu. Dengan itu, begitu Anda memahaminya, semuanya bekerja dengan cara yang sama, yang merupakan nilai tambah besar karena tidak setiap perpustakaan atau bahkan bahasa (saya melihat Anda, PHP) memiliki sintaks yang konsisten.

Tapi dokumentasi samping; plus besar untuk perpustakaan ini adalah ia bekerja berdasarkan opsi JSON. Misalnya, jika Anda ingin menggambar kotak di sudut kanan atas layar, Anda akan melakukan sesuatu seperti ini:

 var box = blessed.box({ top: '0', right: '0', width: '50%', height: '50%', content: 'Hello {bold}world{/bold}!', tags: true, border: { type: 'line' }, style: { fg: 'white', bg: 'magenta', border: { fg: '#f0f0f0' }, hover: { bg: 'green' } } });

Seperti yang dapat Anda bayangkan, aspek lain dari kotak juga ditentukan di sana (seperti ukurannya), yang dapat secara sempurna dinamis berdasarkan ukuran terminal, jenis batas, dan warna — bahkan untuk acara melayang-layang. Jika Anda telah melakukan pengembangan front-end di beberapa titik, Anda akan menemukan banyak tumpang tindih di antara keduanya.

Poin yang saya coba sampaikan di sini adalah bahwa segala sesuatu tentang representasi kotak dikonfigurasi melalui objek JSON yang diteruskan ke metode box . Itu, bagi saya, sempurna karena saya dapat dengan mudah mengekstrak konten itu ke dalam file konfigurasi, dan membuat logika bisnis yang mampu membacanya dan memutuskan elemen mana yang akan digambar di layar. Yang terpenting, ini akan membantu kita melihat sekilas bagaimana mereka akan terlihat setelah digambar.

Ini akan menjadi dasar untuk seluruh aspek UI dari modul ini ( lebih lanjut tentang itu sebentar lagi! ).

Arsitektur Modul

Arsitektur utama modul ini bergantung sepenuhnya pada widget UI yang akan kami tampilkan. Sekelompok widget ini dianggap sebagai layar, dan semua layar ini didefinisikan dalam satu file JSON (yang dapat Anda temukan di dalam folder /config ).

File ini memiliki lebih dari 250 baris, jadi menunjukkannya di sini tidak masuk akal. Anda dapat melihat file lengkapnya secara online, tetapi cuplikan kecilnya terlihat seperti ini:

 "screens": { "main-options": { "file": "./main-options.js", "elements": { "username-request": { "type": "input-prompt", "params": { "position": { "top": "0%", "left": "0%", "width": "100%", "height": "25%" }, "content": "Input your username: ", "inputOnFocus": true, "border": { "type": "line" }, "style": { "fg": "white", "bg": "blue", "border": { "fg": "#f0f0f0" }, "hover": { "bg": "green" } } } }, "options": { "type": "window", "params": { "position": { "top": "25%", "left": "0%", "width": "100%", "height": "50%" }, "content": "Please select an option: \n1. Join an existing game.\n2. Create a new game", "border": { "type": "line" }, "style": { //... } } }, "input": { "type": "input", "handlerPath": "../lib/main-options-handler", //... } } }

Elemen "layar" akan berisi daftar layar di dalam aplikasi. Setiap layar berisi daftar widget (yang akan saya bahas sedikit) dan setiap widget memiliki definisi khusus berkat dan file handler terkait (bila berlaku).

Anda dapat melihat bagaimana setiap elemen "params" (di dalam widget tertentu) mewakili kumpulan parameter aktual yang diharapkan oleh metode yang kita lihat sebelumnya. Kunci lainnya yang ditentukan di sana membantu memberikan konteks tentang jenis widget yang akan dirender dan perilakunya.

Beberapa tempat menarik:

Penangan Layar

Setiap elemen layar memiliki properti file yang mereferensikan kode yang terkait dengan layar itu. Kode ini tidak lain adalah objek yang harus memiliki metode init (logika inisialisasi untuk layar tertentu terjadi di dalamnya). Khususnya, mesin UI utama, akan memanggil metode init dari setiap layar, yang pada gilirannya, harus bertanggung jawab untuk menginisialisasi logika apa pun yang mungkin diperlukan (yaitu menyiapkan peristiwa kotak input).

Berikut ini adalah kode untuk layar utama, di mana aplikasi meminta pemain untuk memilih opsi untuk memulai permainan baru atau bergabung dengan yang sudah ada:

 const logger = require("../utils/logger") module.exports = { init: function(elements, UI) { this.elements = elements this.UI = UI this. this.setInput() }, moveToIDRequest: function(handler) { return this.UI.loadScreen('id-requests', (err, ) => { }) }, createNewGame: function(handler) { handler.createNewGame(this.UI.gamestate.APIKEY, (err, gameData) => { this.UI.gamestate.gameID = gameData._id handler.joinGame(this.UI.gamestate, (err) => { return this.UI.loadScreen('main-ui', { flashmessage: "You've joined game " + this.UI.gamestate.gameID + " successfully" }, (err, ) => { }) }) }) }, setInput: function() { let handler = require(this.elements["input"].meta.handlerPath) let input = this.elements["input"].obj let usernameRequest = this.elements['username-request'].obj let usernameRequestMeta = this.elements['username-request'].meta let question = usernameRequestMeta.params.content.trim() usernameRequest.setValue(question) this.UI.renderScreen() let validOptions = { 1: this.moveToIDRequest.bind(this), 2: this.createNewGame.bind(this) } usernameRequest.on('submit', (username) => { logger.info("Username:" +username) logger.info("Playername: " + username.replace(question, '')) this.UI.gamestate.playername = username.replace(question, '') input.focus() input.on('submit', (data) => { let command = input.getValue() if(!validOptions[+command]) { this.UI.setUpAlert("Invalid option: " + command) return this.UI.renderScreen() } return validOptions[+command](handler) }) }) return input } }

Seperti yang Anda lihat, metode init memanggil metode setupInput yang pada dasarnya mengonfigurasi panggilan balik yang tepat untuk menangani input pengguna. Panggilan balik itu memegang logika untuk memutuskan apa yang harus dilakukan berdasarkan input pengguna (baik 1 atau 2).

Penangan Widget

Beberapa widget (biasanya widget input) memiliki properti handlerPath , yang mereferensikan file yang berisi logika di balik komponen tertentu. Ini tidak sama dengan pengendali layar sebelumnya. Ini tidak terlalu peduli dengan komponen UI. Sebagai gantinya, mereka menangani logika lem antara UI dan perpustakaan apa pun yang kami gunakan untuk berinteraksi dengan layanan eksternal (seperti API mesin game).

Jenis Widget

Tambahan kecil lainnya untuk definisi widget JSON adalah jenisnya. Alih-alih menggunakan nama yang ditentukan Beato untuk mereka, saya membuat yang baru untuk memberi saya lebih banyak ruang gerak dalam hal perilaku mereka. Bagaimanapun, widget jendela mungkin tidak selalu "hanya menampilkan informasi", atau kotak input mungkin tidak selalu bekerja dengan cara yang sama.

Ini sebagian besar merupakan langkah pencegahan, hanya untuk memastikan saya memiliki kemampuan itu jika saya membutuhkannya di masa depan, tetapi seperti yang akan Anda lihat, saya tidak menggunakan banyak jenis komponen yang berbeda.

Beberapa Layar

Meskipun layar utama adalah yang saya tunjukkan pada tangkapan layar di atas, gim ini memerlukan beberapa layar lain untuk meminta hal-hal seperti nama pemain Anda atau apakah Anda membuat sesi gim baru atau bahkan bergabung dengan yang sudah ada. Cara saya menanganinya, sekali lagi, melalui definisi semua layar ini dalam file JSON yang sama. Dan untuk berpindah dari satu layar ke layar berikutnya, kami menggunakan logika di dalam file pengendali layar.

Kita dapat melakukan ini hanya dengan menggunakan baris kode berikut:

 this.UI.loadScreen('main-ui', (err ) => { if(err) this.UI.setUpAlert(err) })

Saya akan menunjukkan lebih banyak detail tentang properti UI dalam sedetik, tetapi saya hanya menggunakan metode loadScreen itu untuk merender ulang layar dan memilih komponen yang tepat dari file JSON menggunakan string yang diteruskan sebagai parameter. Sangat mudah.

Contoh Kode

Sekarang saatnya untuk memeriksa daging dan kentang dari artikel ini: contoh kode. Saya hanya akan menyoroti apa yang menurut saya merupakan permata kecil di dalamnya, tetapi Anda selalu dapat melihat kode sumber lengkapnya langsung di repositori kapan saja.

Menggunakan File Konfigurasi Untuk Menghasilkan UI Secara Otomatis

Saya telah membahas sebagian dari ini, tetapi saya pikir ada baiknya menjelajahi detail di balik generator ini. Inti di baliknya (file index.js di dalam folder /ui ) adalah bahwa itu adalah pembungkus di sekitar objek Blessed. Dan metode yang paling menarik di dalamnya, adalah metode loadScreen .

Metode ini mengambil konfigurasi (melalui modul config) untuk satu layar tertentu dan menelusuri kontennya, mencoba menghasilkan widget yang tepat berdasarkan jenis setiap elemen.

 loadScreen: function(sname, extras, done) { if(typeof extras == "function") { done = extras } let screen = config.get('screens.' + sname) let screenElems = {} if(this.screenElements.length > 0) { //remove previous screen this.screenElements.map( e => e.detach()) this.screen.realloc() } Object.keys(screen.elements).forEach( eName => { let elemObj = null let element = screen.elements[eName] if(element.type == 'window') { elemObj = this.setUpWindow(element) } if(element.type == 'input') { elemObj = this.setUpInputBox(element) } if(element.type == 'input-prompt') { elemObj = this.setUpInputBox(element) } screenElems[eName] = { meta: element, obj: elemObj } }) if(typeof extras === 'object' && extras.flashmessage) { this.setUpAlert(extras.flashmessage) } this.renderScreen() let logicPath = require(screen.file) logicPath.init(screenElems, this) done() },

Seperti yang Anda lihat, kodenya agak panjang, tetapi logika di baliknya sederhana:

  1. Ini memuat konfigurasi untuk layar spesifik saat ini;
  2. Membersihkan widget yang sudah ada sebelumnya;
  3. Pergi ke setiap widget dan instantiates itu;
  4. Jika peringatan ekstra diteruskan sebagai pesan flash (yang pada dasarnya adalah konsep yang saya curi dari Web Dev di mana Anda menyiapkan pesan untuk ditampilkan di layar hingga penyegaran berikutnya);
  5. Render layar yang sebenarnya;
  6. Dan akhirnya, minta pengendali layar dan jalankan metode "init".

Itu dia! Anda dapat memeriksa metode lainnya — sebagian besar terkait dengan widget individual dan cara merendernya.

Komunikasi Antara UI Dan Logika Bisnis

Meskipun dalam skala besar, UI, back-end, dan server obrolan semuanya memiliki komunikasi berbasis lapisan; ujung depan itu sendiri membutuhkan setidaknya arsitektur internal dua lapis di mana elemen UI murni berinteraksi dengan serangkaian fungsi yang mewakili logika inti di dalam proyek khusus ini.

Diagram berikut menunjukkan arsitektur internal untuk klien teks yang sedang kita bangun:

(Pratinjau besar)

Mari saya jelaskan sedikit lebih jauh. Seperti yang saya sebutkan di atas, loadScreenMethod akan membuat presentasi UI dari widget (ini adalah objek yang diberkati). Tapi mereka terkandung sebagai bagian dari objek logika layar yang merupakan tempat kami mengatur acara dasar (seperti onSubmit untuk kotak input).

Izinkan saya memberi Anda contoh praktis. Berikut adalah layar pertama yang Anda lihat saat memulai klien UI:

(Pratinjau besar)

Ada tiga bagian di layar ini:

  1. Permintaan nama pengguna,
  2. Pilihan menu/informasi,
  3. Layar input untuk opsi menu.

Pada dasarnya, yang ingin kami lakukan adalah meminta nama pengguna dan kemudian meminta mereka untuk memilih salah satu dari dua opsi (baik memulai permainan baru atau bergabung dengan yang sudah ada).

Kode yang menangani itu adalah sebagai berikut:

 module.exports = { init: function(elements, UI) { this.elements = elements this.UI = UI this. this.setInput() }, moveToIDRequest: function(handler) { return this.UI.loadScreen('id-requests', (err, ) => { }) }, createNewGame: function(handler) { handler.createNewGame(this.UI.gamestate.APIKEY, (err, gameData) => { this.UI.gamestate.gameID = gameData._id handler.joinGame(this.UI.gamestate, (err) => { return this.UI.loadScreen('main-ui', { flashmessage: "You've joined game " + this.UI.gamestate.gameID + " successfully" }, (err, ) => { }) }) }) }, setInput: function() { let handler = require(this.elements["input"].meta.handlerPath) let input = this.elements["input"].obj let usernameRequest = this.elements['username-request'].obj let usernameRequestMeta = this.elements['username-request'].meta let question = usernameRequestMeta.params.content.trim() usernameRequest.setValue(question) this.UI.renderScreen() let validOptions = { 1: this.moveToIDRequest.bind(this), 2: this.createNewGame.bind(this) } usernameRequest.on('submit', (username) => { logger.info("Username:" +username) logger.info("Playername: " + username.replace(question, '')) this.UI.gamestate.playername = username.replace(question, '') input.focus() input.on('submit', (data) => { let command = input.getValue() if(!validOptions[+command]) { this.UI.setUpAlert("Invalid option: " + command) return this.UI.renderScreen() } return validOptions[+command](handler) }) }) return input } }

Saya tahu itu banyak kode, tetapi hanya fokus pada metode init . Hal terakhir yang dilakukannya adalah memanggil metode setInput yang menangani penambahan event yang tepat ke kotak input yang tepat.

Oleh karena itu, dengan garis-garis ini:

 let handler = require(this.elements["input"].meta.handlerPath) let input = this.elements["input"].obj let usernameRequest = this.elements['username-request'].obj let usernameRequestMeta = this.elements['username-request'].meta let question = usernameRequestMeta.params.content.trim()

Kami sedang mengakses objek-objek yang diberkati dan mendapatkan referensinya, sehingga nanti kami dapat mengatur acara submit . Jadi setelah kami mengirimkan nama pengguna, kami mengalihkan fokus ke kotak input kedua (secara harfiah dengan input.focus() ).

Bergantung pada opsi apa yang kami pilih dari menu, kami memanggil salah satu metode:

  • createNewGame : membuat game baru dengan berinteraksi dengan handler terkait;
  • moveToIDRequest : merender layar berikutnya yang bertugas meminta ID game untuk bergabung.

Komunikasi Dengan Mesin Game

Terakhir tetapi tentu tidak kalah pentingnya (dan mengikuti contoh di atas), jika Anda menekan 2, Anda akan melihat bahwa metode createNewGame menggunakan metode handler createNewGame dan kemudian joinGame (bergabung dengan game segera setelah membuatnya).

Kedua metode ini dimaksudkan untuk menyederhanakan interaksi dengan API Game Engine. Berikut adalah kode untuk penangan layar ini:

 const request = require("request"), config = require("config"), apiClient = require("./apiClient") let API = config.get("api") module.exports = { joinGame: function(apikey, gameId, cb) { apiClient.joinGame(apikey, gameId, cb) }, createNewGame: function(apikey, cb) { request.post(API.url + API.endpoints.games + "?apikey=" + apikey, { //creating game body: { cartridgeid: config.get("app.game.cartdrigename") }, json: true }, (err, resp, body) => { cb(null, body) }) } }

Di sana Anda melihat dua cara berbeda untuk menangani perilaku ini. Metode pertama sebenarnya menggunakan kelas apiClient , yang sekali lagi, membungkus interaksi dengan GameEngine ke dalam lapisan abstraksi lainnya.

Metode kedua meskipun melakukan tindakan secara langsung dengan mengirimkan permintaan POST ke URL yang benar dengan muatan yang tepat. Tidak ada yang mewah dilakukan sesudahnya; kami hanya mengirimkan isi respons kembali ke logika UI.

Catatan : Jika Anda tertarik dengan versi lengkap kode sumber untuk klien ini, Anda dapat memeriksanya di sini.

Kata-kata Terakhir

Ini dia untuk klien berbasis teks untuk petualangan teks kami. saya menutupi:

  • Bagaimana menyusun aplikasi klien;
  • Bagaimana saya menggunakan Blessed sebagai teknologi inti untuk membuat lapisan presentasi;
  • Bagaimana menyusun interaksi dengan layanan back-end dari klien yang kompleks;
  • Dan mudah-mudahan, dengan repositori lengkap tersedia.

Dan sementara UI mungkin tidak terlihat persis seperti versi aslinya, itu memenuhi tujuannya. Semoga artikel ini memberi Anda gambaran tentang bagaimana merancang usaha semacam itu dan Anda cenderung mencobanya sendiri di masa depan. Diberkati jelas merupakan alat yang sangat kuat, tetapi Anda harus bersabar dengannya sambil mempelajari cara menggunakannya dan cara menavigasi dokumen mereka.

Di bagian berikutnya dan terakhir, saya akan membahas bagaimana saya menambahkan server obrolan baik di back-end maupun untuk klien teks ini.

Sampai jumpa di yang berikutnya!

Bagian Lain Dari Seri Ini

  • Bagian 1: Pendahuluan
  • Bagian 2: Desain Server Mesin Game
  • Bagian 4: Menambahkan Obrolan Ke Game Kami