Pisanie wieloosobowego silnika tekstowego przygodowego w Node.js: tworzenie klienta terminala (część 3)

Opublikowany: 2022-03-10
Szybkie podsumowanie ↬ Ta trzecia część serii skupi się na dodaniu klienta tekstowego do silnika gry, który został stworzony w części 2. Fernando Doglio wyjaśnia podstawowy projekt architektury, wybór narzędzi i podświetlenia kodu, pokazując, jak stworzyć tekst- oparty na interfejsie użytkownika za pomocą Node.js.

Najpierw pokazałem ci, jak zdefiniować projekt taki jak ten, i przedstawiłem podstawy architektury, a także mechanikę silnika gry. Następnie pokazałem podstawową implementację silnika — podstawowe API REST, które pozwala przemierzać świat zdefiniowany przez JSON.

Dzisiaj pokażę, jak stworzyć oldschoolowego klienta tekstowego dla naszego API, używając tylko Node.js.

Inne części tej serii

  • Część 1: Wprowadzenie
  • Część 2: Projekt serwera silnika gry
  • Część 4: Dodawanie czatu do naszej gry

Przeglądanie oryginalnego projektu

Kiedy po raz pierwszy zaproponowałem podstawowy szkielet interfejsu użytkownika, zaproponowałem cztery sekcje na ekranie:

(duży podgląd)

Chociaż teoretycznie wygląda to dobrze, przegapiłem fakt, że przełączanie się między wysyłaniem poleceń gry i wiadomości tekstowych byłoby uciążliwe, więc zamiast ręcznego przełączania graczy, nasz parser poleceń upewni się, że jest w stanie rozpoznać, czy my próbujesz komunikować się z grą lub naszymi przyjaciółmi.

Tak więc zamiast czterech sekcji na naszym ekranie, będziemy mieć teraz trzy:

(duży podgląd)

To jest rzeczywisty zrzut ekranu końcowego klienta gry. Możesz zobaczyć ekran gry po lewej stronie i czat po prawej, z pojedynczym, wspólnym polem wprowadzania na dole. Używany przez nas moduł pozwala nam dostosować kolory i niektóre podstawowe efekty. Będziesz mógł sklonować ten kod z Github i robić, co chcesz, z wyglądem i działaniem.

Jedno zastrzeżenie: chociaż powyższy zrzut ekranu pokazuje czat działający jako część aplikacji, ten artykuł skupimy się na skonfigurowaniu projektu i zdefiniowaniu frameworka, w którym możemy stworzyć dynamiczną aplikację opartą na tekstowym interfejsie użytkownika. Skoncentrujemy się na dodaniu obsługi czatu w następnym i ostatnim rozdziale tej serii.

Więcej po skoku! Kontynuuj czytanie poniżej ↓

Narzędzia, których będziemy potrzebować

Chociaż istnieje wiele bibliotek, które pozwalają nam tworzyć narzędzia CLI za pomocą Node.js, dodanie tekstowego interfejsu użytkownika to zupełnie inna bestia do oswojenia. W szczególności udało mi się znaleźć tylko jedną (bardzo kompletną, pamiętaj) bibliotekę, która pozwoliła mi zrobić dokładnie to, czego chciałem: Błogosławiony.

Ta biblioteka jest bardzo potężna i zapewnia wiele funkcji, których nie będziemy używać w tym projekcie (takich jak rzucanie cieni, przeciąganie i upuszczanie i inne). Zasadniczo ponownie implementuje całą bibliotekę ncurses (bibliotekę C, która pozwala programistom na tworzenie tekstowych interfejsów użytkownika), która nie ma powiązań Node.js i robi to bezpośrednio w JavaScript; więc gdybyśmy musieli, moglibyśmy bardzo dobrze sprawdzić jego wewnętrzny kod (coś, czego nie polecałbym, chyba że absolutnie musiałeś).

Chociaż dokumentacja dla Błogosławionego jest dość obszerna, składa się głównie z indywidualnych szczegółów na temat każdej dostarczonej metody (w przeciwieństwie do samouczków wyjaśniających, jak faktycznie używać tych metod razem) i wszędzie brakuje przykładów, więc może być trudno się w nią zagłębić jeśli musisz zrozumieć, jak działa konkretna metoda. Mając to na uwadze, kiedy już to zrozumiesz, wszystko działa w ten sam sposób, co jest dużym plusem, ponieważ nie każda biblioteka, a nawet język (patrzę na ciebie, PHP) ma spójną składnię.

Ale dokumentacja na bok; dużym plusem tej biblioteki jest to, że działa ona w oparciu o opcje JSON. Na przykład, jeśli chciałbyś narysować prostokąt w prawym górnym rogu ekranu, zrobiłbyś coś takiego:

 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' } } });

Jak możesz sobie wyobrazić, zdefiniowane są tam również inne aspekty pudełka (takie jak jego rozmiar), które mogą być doskonale dynamiczne w oparciu o rozmiar terminala, rodzaj obramowania i kolory — nawet w przypadku najechania kursorem. Jeśli w pewnym momencie wykonałeś programowanie front-endowe, zauważysz, że dużo się między nimi nakłada.

Chodzi mi o to, że wszystko, co dotyczy reprezentacji pudełka, jest konfigurowane za pomocą obiektu JSON przekazanego do metody box . To dla mnie idealne, ponieważ mogę łatwo wyodrębnić tę zawartość do pliku konfiguracyjnego i stworzyć logikę biznesową zdolną do jej odczytania i decydowania, które elementy narysować na ekranie. Co najważniejsze, pomoże nam to zobaczyć, jak będą wyglądać po narysowaniu.

Będzie to podstawa całego aspektu interfejsu użytkownika tego modułu ( więcej o tym za chwilę! ).

Architektura modułu

Główna architektura tego modułu opiera się całkowicie na widżetach interfejsu użytkownika, które będziemy pokazywać. Grupa tych widżetów jest uważana za ekran, a wszystkie te ekrany są zdefiniowane w jednym pliku JSON (który można znaleźć w folderze /config ).

Ten plik ma ponad 250 wierszy, więc pokazywanie go tutaj nie ma sensu. Możesz obejrzeć cały plik online, ale jego mały fragment wygląda tak:

 "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", //... } } }

Element „screens” będzie zawierał listę ekranów wewnątrz aplikacji. Każdy ekran zawiera listę widżetów (którą za chwilę omówię), a każdy widżet ma swoją definicję specyficzną dla błogosławieństwa i powiązane pliki obsługi (jeśli dotyczy).

Możesz zobaczyć, jak każdy element „params” (wewnątrz konkretnego widżetu) reprezentuje rzeczywisty zestaw parametrów oczekiwanych przez metody, które widzieliśmy wcześniej. Pozostałe zdefiniowane tam klucze pomagają zapewnić kontekst dotyczący rodzaju renderowanych widżetów i ich zachowania.

Kilka ciekawostek:

Obsługa ekranu

Każdy element ekranu ma właściwość pliku, która odwołuje się do kodu powiązanego z tym ekranem. Ten kod to nic innego jak obiekt, który musi mieć metodę init (logika inicjalizacji dla tego konkretnego ekranu odbywa się w nim). W szczególności główny silnik interfejsu użytkownika wywoła metodę init na każdym ekranie, która z kolei powinna być odpowiedzialna za inicjalizację wymaganej logiki (tj. ustawianie zdarzeń pól wejściowych).

Poniżej znajduje się kod do ekranu głównego, na którym aplikacja prosi gracza o wybranie opcji rozpoczęcia zupełnie nowej gry lub dołączenia do już istniejącej:

 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 } }

Jak widać, metoda init wywołuje metodę setupInput , która zasadniczo konfiguruje właściwe wywołanie zwrotne do obsługi danych wejściowych użytkownika. To wywołanie zwrotne zawiera logikę, która decyduje, co zrobić na podstawie danych wejściowych użytkownika (albo 1 lub 2).

Programy obsługi widżetów

Niektóre widżety (zwykle widżety wejściowe) mają właściwość handlerPath , która odwołuje się do pliku zawierającego logikę danego komponentu. To nie to samo, co poprzedni program obsługi ekranu. Nie przejmują się one tak bardzo komponentami interfejsu użytkownika. Zamiast tego obsługują logikę klejenia między interfejsem użytkownika a dowolną biblioteką, której używamy do interakcji z usługami zewnętrznymi (takimi jak API silnika gry).

Typy widżetów

Kolejnym niewielkim dodatkiem do definicji JSON widżetów są ich typy. Zamiast korzystać z imion, które zdefiniowali dla nich Błogosławieni, tworzę nowe, aby dać mi więcej swobody, jeśli chodzi o ich zachowanie. W końcu widżet okna może nie zawsze „wyświetlać tylko informacje” lub pole wprowadzania może nie zawsze działać w ten sam sposób.

Był to głównie ruch wyprzedzający, aby upewnić się, że mam tę umiejętność, jeśli kiedykolwiek będę jej potrzebować w przyszłości, ale jak zaraz zobaczysz, i tak nie używam tak wielu różnych typów komponentów.

Wiele ekranów

Chociaż główny ekran jest tym, który pokazałem na powyższym zrzucie ekranu, gra wymaga kilku innych ekranów, aby poprosić o takie rzeczy, jak nazwa gracza lub to, czy tworzysz zupełnie nową sesję gry, czy nawet dołączasz do już istniejącej. Sposób, w jaki sobie z tym poradziłem, polegał na zdefiniowaniu wszystkich tych ekranów w tym samym pliku JSON. Aby przejść z jednego ekranu do następnego, używamy logiki wewnątrz plików obsługi ekranu.

Możemy to zrobić po prostu za pomocą następującego wiersza kodu:

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

Za chwilę pokażę więcej szczegółów na temat właściwości UI, ale po prostu używam tej metody loadScreen do ponownego renderowania ekranu i wybierania odpowiednich komponentów z pliku JSON za pomocą ciągu przekazanego jako parametr. Bardzo proste.

Próbki kodu

Nadszedł czas, aby sprawdzić mięso i ziemniaki z tego artykułu: próbki kodu. Chcę tylko podkreślić to, co moim zdaniem jest małymi perełkami w środku, ale zawsze możesz rzucić okiem na pełny kod źródłowy bezpośrednio w repozytorium w dowolnym momencie.

Używanie plików konfiguracyjnych do automatycznego generowania interfejsu użytkownika

Część tego już omówiłem, ale myślę, że warto poznać szczegóły dotyczące tego generatora. Istota tego (plik index.js w folderze /ui ) jest taka, że ​​jest to opakowanie wokół obiektu Błogosławionego. A najciekawszą w nim metodą jest metoda loadScreen .

Ta metoda pobiera konfigurację (poprzez moduł config) dla jednego konkretnego ekranu i przechodzi przez jego zawartość, próbując wygenerować odpowiednie widżety na podstawie typu każdego elementu.

 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() },

Jak widać, kod jest nieco długi, ale logika stojąca za nim jest prosta:

  1. Ładuje konfigurację dla bieżącego określonego ekranu;
  2. Czyści wszelkie wcześniej istniejące widżety;
  3. Przegląda każdy widżet i tworzy jego instancję;
  4. Jeśli dodatkowy alert został przekazany jako wiadomość flash (co jest w zasadzie koncepcją, którą ukradłem od Web Dev, w której ustawiasz wiadomość, która ma być wyświetlana na ekranie do następnego odświeżenia);
  5. Renderuj rzeczywisty ekran;
  6. I na koniec, zażądaj obsługi ekranu i wykonaj jego metodę „init”.

Otóż ​​to! Możesz sprawdzić pozostałe metody — są one w większości związane z poszczególnymi widżetami i sposobem ich renderowania.

Komunikacja między interfejsem użytkownika a logiką biznesową

Chociaż na wielką skalę, interfejs użytkownika, zaplecze i serwer czatu mają komunikację opartą na warstwach; sam frontend wymaga co najmniej dwuwarstwowej architektury wewnętrznej, w której czyste elementy interfejsu użytkownika współdziałają z zestawem funkcji reprezentujących podstawową logikę w tym konkretnym projekcie.

Poniższy diagram przedstawia wewnętrzną architekturę tworzonego przez nas klienta tekstowego:

(duży podgląd)

Pozwólcie, że wyjaśnię to nieco dalej. Jak wspomniałem powyżej, loadScreenMethod utworzy prezentacje interfejsu użytkownika widżetów (są to obiekty Błogosławione). Są one jednak zawarte jako część obiektu logicznego ekranu, w którym ustawiamy podstawowe zdarzenia (takie jak onSubmit dla pól wejściowych).

Pozwólcie, że podam praktyczny przykład. Oto pierwszy ekran, który zobaczysz po uruchomieniu klienta interfejsu użytkownika:

(duży podgląd)

Na tym ekranie znajdują się trzy sekcje:

  1. Prośba o nazwę użytkownika,
  2. Opcje menu / informacje,
  3. Ekran wprowadzania opcji menu.

Zasadniczo chcemy poprosić o nazwę użytkownika, a następnie poprosić ich o wybranie jednej z dwóch opcji (zarówno uruchomienie zupełnie nowej gry, jak i dołączenie do już istniejącej).

Kod, który się tym zajmuje, to:

 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 } }

Wiem, że to dużo kodu, ale skup się tylko na metodzie init . Ostatnią rzeczą, jaką robi, jest wywołanie metody setInput , która zajmuje się dodawaniem odpowiednich zdarzeń do odpowiednich pól wejściowych.

Dlatego z tymi liniami:

 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()

Uzyskujemy dostęp do obiektów Błogosławionych i uzyskujemy ich referencje, dzięki czemu możemy później ustawić wydarzenia związane z submit . Więc po przesłaniu nazwy użytkownika przełączamy fokus na drugie pole wprowadzania (dosłownie za pomocą input.focus() ).

W zależności od tego, jaką opcję wybierzemy z menu, wywołujemy jedną z metod:

  • createNewGame : tworzy nową grę poprzez interakcję z powiązanym z nią handlem;
  • moveToIDRequest : renderuje następny ekran odpowiedzialny za żądanie identyfikatora gry, aby dołączyć.

Komunikacja z silnikiem gry

Na koniec (i podążając za powyższym przykładem), jeśli trafisz 2, zauważysz, że metoda createNewGame wykorzystuje metody obsługi createNewGame , a następnie joinGame (dołączanie do gry zaraz po jej utworzeniu).

Obie te metody mają na celu uproszczenie interakcji z API Game Engine. Oto kod obsługi tego ekranu:

 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) }) } }

Tam widzisz dwa różne sposoby radzenia sobie z tym zachowaniem. Pierwsza metoda faktycznie wykorzystuje klasę apiClient , która ponownie opakowuje interakcje z GameEngine w kolejną warstwę abstrakcji.

Jednak druga metoda wykonuje akcję bezpośrednio, wysyłając żądanie POST do prawidłowego adresu URL z odpowiednim ładunkiem. Potem nie robi się nic wymyślnego; po prostu wysyłamy treść odpowiedzi z powrotem do logiki interfejsu użytkownika.

Uwaga : Jeśli interesuje Cię pełna wersja kodu źródłowego dla tego klienta, możesz ją sprawdzić tutaj.

Ostatnie słowa

To wszystko dla klienta tekstowego do naszej tekstowej przygody. omówiłem:

  • Jak ustrukturyzować aplikację kliencką;
  • Jak wykorzystałem Błogosławioną jako podstawową technologię do tworzenia warstwy prezentacji;
  • Jak ustrukturyzować interakcję z usługami zaplecza od złożonego klienta;
  • I miejmy nadzieję, że dostępne będzie pełne repozytorium.

I chociaż interfejs użytkownika może nie wyglądać dokładnie tak, jak oryginalna wersja, spełnia swój cel. Mam nadzieję, że ten artykuł dał ci pomysł, jak zaprojektować takie przedsięwzięcie i chciałeś spróbować go w przyszłości. Błogosławieństwo to zdecydowanie bardzo potężne narzędzie, ale będziesz musiał uzbroić się w cierpliwość, ucząc się, jak z niego korzystać i jak poruszać się po ich dokumentach.

W następnej i ostatniej części omówię, jak dodałem serwer czatu zarówno na zapleczu, jak i dla tego klienta tekstowego.

Do zobaczenia na następnym!

Inne części tej serii

  • Część 1: Wprowadzenie
  • Część 2: Projekt serwera silnika gry
  • Część 4: Dodawanie czatu do naszej gry