Pisanie wieloosobowego silnika tekstowego przygodowego w Node.js: projektowanie serwera silnika gry (część 2)

Opublikowany: 2022-03-10
Szybkie podsumowanie ↬ Witamy w drugiej części tej serii. W pierwszej części omówiliśmy architekturę platformy opartej na Node.js i aplikacji klienckiej, które umożliwią ludziom definiowanie i rozgrywanie własnych tekstowych przygód w grupie. Tym razem zajmiemy się tworzeniem jednego z modułów zdefiniowanych ostatnio przez Fernando (silnik gry), a także skupimy się na procesie projektowania, aby rzucić nieco światła na to, co musi się wydarzyć, zanim zaczniesz kodować swoje własne projekty hobbystyczne.

Po dokładnym rozważeniu i faktycznej implementacji modułu, niektóre definicje, które stworzyłem w fazie projektowania, musiały zostać zmienione. Powinna to być znajoma scena dla każdego, kto kiedykolwiek pracował z chętnym klientem, który marzy o idealnym produkcie, ale musi być powściągliwy przez zespół programistów.

Gdy funkcje zostaną wdrożone i przetestowane, Twój zespół zacznie zauważać, że niektóre cechy mogą różnić się od pierwotnego planu, i to jest w porządku. Po prostu powiadom, dostosuj i kontynuuj. Więc bez dalszych ceregieli pozwólcie, że najpierw wyjaśnię, co się zmieniło od pierwotnego planu.

Inne części tej serii

  • Część 1: Wprowadzenie
  • Część 3: Tworzenie klienta terminala
  • Część 4: Dodawanie czatu do naszej gry

Mechanika bitwy

To chyba największa zmiana w stosunku do pierwotnego planu. Wiem, że powiedziałem, że zamierzam wybrać implementację D&D-esque, w której każdy zaangażowany PC i NPC otrzyma wartość inicjatywy, a następnie przeprowadzimy turową walkę. To był fajny pomysł, ale zaimplementowanie go w usłudze opartej na REST jest nieco skomplikowane, ponieważ nie można zainicjować komunikacji od strony serwera ani utrzymać statusu między połączeniami.

Więc zamiast tego skorzystam z uproszczonej mechaniki REST i wykorzystam ją do uproszczenia naszej mechaniki bitwy. Zaimplementowana wersja będzie oparta na graczach, a nie na drużynie, i pozwoli graczom atakować NPC (postacie nie będące graczami). Jeśli ich atak się powiedzie, NPC zostaną zabici lub zaatakują, raniąc lub zabijając gracza.

To, czy atak się powiedzie, czy nie, będzie zależeć od rodzaju użytej broni i słabości, jakie może mieć NPC. Więc w zasadzie, jeśli potwór, którego próbujesz zabić, jest słaby w stosunku do twojej broni, umiera. W przeciwnym razie pozostanie nienaruszona i — najprawdopodobniej — bardzo zła.

Wyzwalacze

Jeśli zwróciłeś szczególną uwagę na definicję gry JSON z mojego poprzedniego artykułu, być może zauważyłeś definicję wyzwalacza znalezioną w elementach sceny. Konkretny dotyczył aktualizacji stanu gry ( statusUpdate ). Podczas wdrażania zdałem sobie sprawę, że działający jako przełącznik zapewnia ograniczoną swobodę. Widzisz, w sposobie, w jaki zostało to zaimplementowane (z idiomatycznego punktu widzenia), mogłeś ustawić status, ale rozbrojenie nie było opcją. Zamiast tego zastąpiłem ten efekt wyzwalacza dwoma nowymi: addStatus i removeStatus . Pozwolą ci one dokładnie określić, kiedy te efekty mogą mieć miejsce — jeśli w ogóle. Czuję, że jest to o wiele łatwiejsze do zrozumienia i uzasadnienia.

Oznacza to, że wyzwalacze wyglądają teraz tak:

 "triggers": [ { "action": "pickup", "effect":{ "addStatus": "has light", "target": "game" } }, { "action": "drop", "effect": { "removeStatus": "has light", "target": "game" } } ]

Podnosząc przedmiot ustawiamy status, a upuszczając go usuwamy. W ten sposób posiadanie wielu wskaźników stanu na poziomie gry jest całkowicie możliwe i łatwe w zarządzaniu.

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

Implementacja

Po usunięciu tych aktualizacji możemy zacząć obejmować rzeczywistą implementację. Z architektonicznego punktu widzenia nic się nie zmieniło; wciąż budujemy API REST, które będzie zawierało logikę głównego silnika gry.

Stos technologiczny

W przypadku tego konkretnego projektu moduły, których zamierzam użyć, są następujące:

Moduł Opis
Express.js Oczywiście będę używał Express jako podstawy dla całego silnika.
Winston Wszystkimi kwestiami związanymi z logowaniem zajmie się Winston.
Konfiguracja Każda zmienna stała i zależna od środowiska będzie obsługiwana przez moduł config.js, co znacznie upraszcza zadanie dostępu do nich.
Mangusta To będzie nasz ORM. Zamodeluję wszystkie zasoby za pomocą modeli Mongoose i użyję ich do bezpośredniej interakcji z bazą danych.
uuid Musimy wygenerować kilka unikalnych identyfikatorów — ten moduł pomoże nam w tym zadaniu.

Jeśli chodzi o inne technologie używane poza Node.js, mamy MongoDB i Redis . Lubię używać Mongo ze względu na brak wymaganego schematu. Ten prosty fakt pozwala mi myśleć o swoim kodzie i formatach danych bez martwienia się o aktualizowanie struktury moich tabel, migracje schematów lub konflikty typów danych.

Jeśli chodzi o Redis, staram się używać go jako systemu wsparcia tak bardzo, jak tylko mogę w moich projektach i nie inaczej jest w tym przypadku. Będę używał Redis do wszystkiego, co można uznać za niestabilne informacje, takie jak numery członków partii, żądania poleceń i inne rodzaje danych, które są wystarczająco małe i niestabilne, aby nie zasługiwać na trwałe przechowywanie.

Zamierzam również użyć kluczowej funkcji wygaśnięcia Redis, aby automatycznie zarządzać niektórymi aspektami przepływu (więcej o tym wkrótce).

Definicja API

Przed przejściem do interakcji klient-serwer i definicji przepływu danych chcę omówić punkty końcowe zdefiniowane dla tego API. Nie jest ich tak wiele, w większości musimy dostosować się do głównych cech opisanych w Części 1:

Funkcja Opis
Dołącz do gry Gracz będzie mógł dołączyć do gry, podając identyfikator gry.
Utwórz nową grę Gracz może również stworzyć nową instancję gry. Silnik powinien zwrócić identyfikator, aby inni mogli go użyć do dołączenia.
Scena powrotu Ta funkcja powinna zwrócić bieżącą scenę, w której znajduje się impreza. Zasadniczo zwróci opis ze wszystkimi powiązanymi informacjami (możliwe akcje, zawarte w nim obiekty itp.).
Wejdź w interakcję ze sceną Będzie to jeden z najbardziej skomplikowanych, ponieważ przyjmie polecenie od klienta i wykona tę akcję — takie rzeczy jak przenoszenie, pchanie, branie, patrzenie, czytanie, żeby wymienić tylko kilka.
Sprawdź stan magazynowy Chociaż jest to sposób na interakcję z grą, nie odnosi się to bezpośrednio do sceny. Tak więc sprawdzenie ekwipunku dla każdego gracza będzie traktowane jako inna czynność.
Zarejestruj aplikację kliencką Powyższe czynności wymagają poprawnego klienta do ich wykonania. Ten punkt końcowy zweryfikuje aplikację kliencką i zwróci identyfikator klienta, który będzie używany do celów uwierzytelniania w kolejnych żądaniach.

Powyższa lista przekłada się na następującą listę punktów końcowych:

Czasownik Punkt końcowy Opis
POCZTA /clients Aplikacje klienckie będą wymagały uzyskania klucza identyfikatora klienta przy użyciu tego punktu końcowego.
POCZTA /games Nowe instancje gier są tworzone przy użyciu tego punktu końcowego przez aplikacje klienckie.
POCZTA /games/:id Po utworzeniu gry ten punkt końcowy umożliwi członkom drużyny dołączenie do niej i rozpoczęcie gry.
DOSTWAĆ /games/:id/:playername Ten punkt końcowy zwróci aktualny stan gry dla konkretnego gracza.
POCZTA /games/:id/:playername/commands Wreszcie z tym punktem końcowym aplikacja kliencka będzie mogła przesyłać polecenia (innymi słowy, ten punkt końcowy będzie używany do gry).

Pozwólcie, że omówię nieco bardziej szczegółowo niektóre koncepcje, które opisałem na poprzedniej liście.

Aplikacje klienckie

Aplikacje klienckie będą musiały zarejestrować się w systemie, aby zacząć z niego korzystać. Wszystkie punkty końcowe (z wyjątkiem pierwszego na liście) są zabezpieczone i będą wymagały przesłania ważnego klucza aplikacji wraz z żądaniem. Aby uzyskać ten klucz, aplikacje klienckie muszą po prostu go zażądać. Raz dostarczone będą działać tak długo, jak będą używane lub wygasną po miesiącu nieużywania. To zachowanie jest kontrolowane przez przechowywanie klucza w Redis i ustawienie dla niego jednomiesięcznego czasu TTL.

Instancja gry

Tworzenie nowej gry w zasadzie oznacza tworzenie nowej instancji konkretnej gry. Ta nowa instancja będzie zawierać kopię wszystkich scen i ich zawartości. Wszelkie modyfikacje dokonane w grze wpłyną tylko na imprezę. W ten sposób wiele grup może grać w tę samą grę na swój indywidualny sposób.

Stan gry gracza

Jest to podobne do poprzedniego, ale unikalne dla każdego gracza. Podczas gdy instancja gry przechowuje stan gry dla całej drużyny, stan gry gracza zachowuje aktualny stan dla jednego konkretnego gracza. Głównie jest to ekwipunek, pozycja, aktualna scena i HP (punkty zdrowia).

Polecenia gracza

Gdy wszystko jest skonfigurowane, a aplikacja kliencka zarejestruje się i dołączy do gry, może zacząć wysyłać polecenia. Zaimplementowane w tej wersji silnika komendy to: move , look , pickup i attack .

  • Polecenie move pozwoli ci przemierzać mapę. Będziesz mógł określić kierunek, w którym chcesz się poruszać, a silnik poinformuje Cię o wyniku. Jeśli rzucisz okiem na część 1, możesz zobaczyć podejście, które zastosowałem do obsługi map. (W skrócie mapa jest reprezentowana jako wykres, gdzie każdy węzeł reprezentuje pomieszczenie lub scenę i jest połączony tylko z innymi węzłami reprezentującymi sąsiednie pomieszczenia.)

    Odległość między węzłami jest również obecna w reprezentacji i połączona ze standardową prędkością gracza; przechodzenie z pokoju do pokoju może nie być tak proste, jak wydawanie komendy, ale będziesz też musiał przebyć odległość. W praktyce oznacza to, że przejście z jednego pokoju do drugiego może wymagać kilku poleceń ruchu). Inny interesujący aspekt tego polecenia wynika z faktu, że silnik ten ma wspierać imprezy dla wielu graczy, a impreza nie może zostać podzielona (przynajmniej nie w tej chwili).

    Dlatego rozwiązanie tego jest podobne do systemu głosowania: każdy członek partii wyśle ​​żądanie polecenia ruchu, kiedy tylko zechce. Gdy zrobi to więcej niż połowa z nich, zostanie użyty najbardziej pożądany kierunek.
  • look jest zupełnie inny niż ruch. Pozwala graczowi określić kierunek, przedmiot lub NPC, który chce zbadać. Kluczowa logika stojąca za tym poleceniem jest brana pod uwagę, gdy myślisz o opisach zależnych od statusu.

    Załóżmy na przykład, że wchodzisz do nowego pokoju, ale jest w nim zupełnie ciemno (nic nie widzisz) i idziesz dalej, ignorując to. Kilka pokoi później podnosisz zapaloną pochodnię ze ściany. Więc teraz możesz wrócić i ponownie sprawdzić ten ciemny pokój. Ponieważ podniosłeś pochodnię, możesz teraz zajrzeć do jej wnętrza i móc wchodzić w interakcje z dowolnymi przedmiotami i NPC, które tam znajdziesz.

    Osiąga się to poprzez zachowanie zestawu atrybutów statusu dla całej gry i konkretnego gracza oraz umożliwienie twórcy gry określenie kilku opisów dla naszych elementów zależnych od statusu w pliku JSON. Każdy opis jest wtedy opatrzony domyślnym tekstem oraz zestawem warunkowych, w zależności od aktualnego stanu. Te ostatnie są opcjonalne; jedyną obowiązkową wartością jest wartość domyślna.

    Dodatkowo to polecenie ma skróconą wersję dla look at room: look around ; dzieje się tak dlatego, że gracze będą bardzo często próbować sprawdzać pokój, więc podanie polecenia skrótu (lub aliasu), które jest łatwiejsze do wpisania, ma wiele sensu.
  • Polecenie pickup odgrywa bardzo ważną rolę w rozgrywce. To polecenie zajmuje się dodawaniem przedmiotów do ekwipunku graczy lub ich rąk (jeśli są wolne). Aby zrozumieć, gdzie ma być przechowywany każdy przedmiot, ich definicja ma właściwość „miejsce docelowe”, która określa, czy jest przeznaczony do ekwipunku, czy do rąk gracza. Wszystko, co zostało pomyślnie pobrane ze sceny, jest następnie z niej usuwane, aktualizując wersję gry instancji gry.
  • Polecenie use pozwoli ci wpływać na środowisko za pomocą przedmiotów w ekwipunku. Na przykład podniesienie klucza w pokoju pozwoli ci użyć go do otwarcia zamkniętych drzwi w innym pokoju.
  • Istnieje specjalne polecenie, które nie jest związane z rozgrywką, ale polecenie pomocnicze, które ma na celu uzyskanie określonych informacji, takich jak bieżący identyfikator gry lub imię gracza. To polecenie nazywa się get i gracze mogą go używać do wysyłania zapytań do silnika gry. Na przykład: pobierz identyfikator gry .
  • Ostatnią komendą zaimplementowaną w tej wersji silnika jest komenda attack . Już omówiłem ten; w zasadzie będziesz musiał określić swój cel i broń, którą go atakujesz. W ten sposób system będzie w stanie sprawdzić słabości celu i określić wynik twojego ataku.

Interakcja klient-silnik

Aby zrozumieć, jak korzystać z wyżej wymienionych punktów końcowych, pokażę, jak każdy potencjalny klient może wchodzić w interakcję z naszym nowym interfejsem API.

Krok Opis
Zarejestruj klienta Po pierwsze, aplikacja kliencka musi zażądać klucza API, aby móc uzyskać dostęp do wszystkich innych punktów końcowych. Aby uzyskać ten klucz, należy zarejestrować się na naszej platformie. Jedynym parametrem, który należy podać, jest nazwa aplikacji, to wszystko.
Utwórz grę Po uzyskaniu klucza API pierwszą rzeczą do zrobienia (zakładając, że jest to zupełnie nowa interakcja) jest utworzenie zupełnie nowej instancji gry. Pomyśl o tym w ten sposób: plik JSON, który stworzyłem w moim ostatnim poście, zawiera definicję gry, ale musimy stworzyć jego instancję tylko dla Ciebie i Twojej drużyny (pomyśl o klasach i obiektach, to samo). Możesz zrobić z tą instancją, co chcesz i nie wpłynie to na inne strony.
Dołącz do gry Po utworzeniu gry otrzymasz identyfikator gry z powrotem z silnika. Następnie możesz użyć tego identyfikatora gry, aby dołączyć do instancji, używając swojej unikalnej nazwy użytkownika. Jeśli nie dołączysz do gry, nie możesz grać, ponieważ dołączenie do gry spowoduje również utworzenie instancji stanu gry tylko dla Ciebie. W tym miejscu zapisywane są twój ekwipunek, pozycja i podstawowe statystyki związane z grą, w którą grasz. Potencjalnie możesz grać w kilka gier jednocześnie iw każdej z nich mieć niezależne stany.
Wyślij polecenia Innymi słowy: zagraj w grę. Ostatnim krokiem jest rozpoczęcie wysyłania poleceń. Ilość dostępnych poleceń została już omówiona i można ją łatwo rozszerzyć (więcej o tym za chwilę). Za każdym razem, gdy wyślesz polecenie, gra zwróci nowy stan gry, aby twój klient odpowiednio zaktualizował twój widok.

Ubrudźmy sobie ręce

Przeanalizowałem tyle projektów, ile mogłem, w nadziei, że te informacje pomogą ci zrozumieć następną część, więc przejdźmy do nakrętek i śrub silnika gry.

Uwaga : nie będę pokazywał pełnego kodu w tym artykule, ponieważ jest on dość duży i nie w całości jest interesujący. Zamiast tego pokażę bardziej odpowiednie części i link do pełnego repozytorium, jeśli chcesz więcej szczegółów.

Główny plik

Po pierwsze: jest to projekt Express i oparty na nim kod wzorcowy został wygenerowany przy użyciu własnego generatora Express, więc plik app.js powinien być Ci znany. Chcę tylko omówić dwie poprawki, które lubię wprowadzać w tym kodzie, aby uprościć moją pracę.

Najpierw dodaję następujący fragment kodu, aby zautomatyzować włączanie nowych plików tras:

 const requireDir = require("require-dir") const routes = requireDir("./routes") //... Object.keys(routes).forEach( (file) => { let cnt = routes[file] app.use('/' + file, cnt) })

To naprawdę całkiem proste, ale eliminuje potrzebę ręcznego wymagania każdego pliku trasy, który utworzysz w przyszłości. Nawiasem mówiąc, require-dir to prosty moduł, który zajmuje się automatycznym wymaganiem każdego pliku w folderze. Otóż ​​to.

Inną zmianą, którą lubię robić, jest niewielkie podrasowanie mojego modułu obsługi błędów. Naprawdę powinienem zacząć używać czegoś bardziej niezawodnego, ale dla potrzeb mam wrażenie, że to załatwia sprawę:

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

Powyższy kod zajmuje się różnymi typami komunikatów o błędach, z którymi możemy mieć do czynienia — albo pełnymi obiektami, rzeczywistymi obiektami błędów zgłoszonymi przez JavaScript, albo prostymi komunikatami o błędach bez żadnego innego kontekstu. Ten kod weźmie to wszystko i sformatuje do standardowego formatu.

Obsługa poleceń

To kolejny z tych aspektów silnika, który musiał być łatwy do rozszerzenia. W projekcie takim jak ten, całkiem sensowne jest założenie, że w przyszłości pojawią się nowe polecenia. Jeśli jest coś, czego chcesz uniknąć, prawdopodobnie będzie to unikanie wprowadzania zmian w kodzie podstawowym podczas próby dodania czegoś nowego za trzy lub cztery miesiące w przyszłości.

Żadna ilość komentarzy do kodu nie sprawi, że zadanie modyfikacji kodu, którego nie dotykałeś (a nawet nie myślałeś) od kilku miesięcy, będzie łatwe, więc priorytetem jest uniknięcie jak największej liczby zmian. Na szczęście dla nas jest kilka wzorców, które możemy wdrożyć, aby rozwiązać ten problem. W szczególności użyłem mieszanki wzorców Command i Factory.

Zasadniczo zamknąłem zachowanie każdego polecenia w pojedynczej klasie, która dziedziczy z klasy BaseCommand , która zawiera ogólny kod wszystkich poleceń. W tym samym czasie dodałem moduł CommandParser , który przechwytuje ciąg znaków wysłany przez klienta i zwraca rzeczywiste polecenie do wykonania.

Parser jest bardzo prosty, ponieważ wszystkie zaimplementowane polecenia mają teraz aktualne polecenie dotyczące pierwszego słowa (tj. „przesuń się na północ”, „podnieś nóż” itd.), to prosta sprawa podzielenia ciągu i uzyskania pierwszej części:

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

Uwaga : ponownie używam modułu require-dir , aby uprościć włączanie wszelkich istniejących i nowych klas poleceń. Po prostu dodaję go do folderu i cały system jest w stanie go podnieść i użyć.

Mając to na uwadze, można to poprawić na wiele sposobów; na przykład możliwość dodania obsługi synonimów do naszych poleceń byłaby świetną funkcją (więc powiedzenie „przesuń się na północ”, „idź na północ” lub nawet „idź na północ” oznaczałoby to samo). To jest coś, co moglibyśmy scentralizować w tej klasie i wpływać na wszystkie polecenia jednocześnie.

Nie będę wchodził w szczegóły dotyczące żadnego z poleceń, ponieważ znowu jest to za dużo kodu do pokazania tutaj, ale w poniższym kodzie trasy możesz zobaczyć, jak udało mi się uogólnić obsługę istniejących (i przyszłych) poleceń:

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

Wszystkie polecenia wymagają tylko metody run — wszystko inne jest dodatkowe i przeznaczone do użytku wewnętrznego.

Zachęcam do zapoznania się z całym kodem źródłowym (nawet do pobrania i zabawy, jeśli chcesz!). W następnej części tej serii pokażę faktyczną implementację klienta i interakcję z tym API.

Myśli zamykające

Być może nie omówiłem tutaj zbyt wiele mojego kodu, ale nadal mam nadzieję, że artykuł był pomocny, aby pokazać, jak radzę sobie z projektami — nawet po początkowej fazie projektowania. Wydaje mi się, że wiele osób próbuje zacząć kodować jako pierwszą odpowiedź na nowy pomysł, co czasami może zniechęcić programistę, ponieważ nie ma prawdziwego planu ani żadnych celów do osiągnięcia — poza przygotowaniem produktu końcowego ( i jest to zbyt duży kamień milowy do pokonania od pierwszego dnia). Więc znowu, mam nadzieję, że dzięki tym artykułom podzielę się innym sposobem pracy solo (lub jako część małej grupy) nad dużymi projektami.

Mam nadzieję, że podobała Ci się lektura! Zachęcamy do pozostawienia komentarza poniżej z wszelkiego rodzaju sugestiami lub zaleceniami. Chętnie przeczytam, co myślisz i jeśli chcesz rozpocząć testowanie interfejsu API z własnym kodem po stronie klienta.

Do zobaczenia na następnym!

Inne części tej serii

  • Część 1: Wprowadzenie
  • Część 3: Tworzenie klienta terminala
  • Część 4: Dodawanie czatu do naszej gry