Écrire un moteur d'aventure textuelle multijoueur dans Node.js : Conception de serveur de moteur de jeu (Partie 2)
Publié: 2022-03-10Après un examen attentif et une mise en œuvre réelle du module, certaines des définitions que j'ai faites pendant la phase de conception ont dû être modifiées. Cela devrait être une scène familière pour quiconque a déjà travaillé avec un client désireux qui rêve d'un produit idéal mais qui a besoin d'être retenu par l'équipe de développement.
Une fois les fonctionnalités implémentées et testées, votre équipe commencera à remarquer que certaines caractéristiques peuvent différer du plan d'origine, et ce n'est pas grave. Il suffit de notifier, d'ajuster et de continuer. Alors, sans plus tarder, permettez-moi d'abord d'expliquer ce qui a changé par rapport au plan initial.
Autres parties de cette série
- Partie 1 : L'introduction
- Partie 3 : Création du client Terminal
- Partie 4 : Ajouter le chat dans notre jeu
Mécanique de combat
C'est probablement le plus grand changement par rapport au plan initial. Je sais que j'ai dit que j'allais opter pour une implémentation D&D-esque dans laquelle chaque PC et PNJ impliqué obtiendrait une valeur d'initiative et après cela, nous mènerions un combat au tour par tour. C'était une bonne idée, mais l'implémenter sur un service basé sur REST est un peu compliqué car vous ne pouvez pas initier la communication du côté serveur, ni maintenir le statut entre les appels.
Donc, à la place, je vais profiter des mécanismes simplifiés de REST et les utiliser pour simplifier nos mécanismes de combat. La version implémentée sera basée sur les joueurs au lieu d'être basée sur le groupe et permettra aux joueurs d'attaquer les PNJ (personnages non joueurs). Si leur attaque réussit, les PNJ seront tués ou bien ils riposteront en endommageant ou en tuant le joueur.
Le succès ou l'échec d'une attaque sera déterminé par le type d'arme utilisée et les faiblesses qu'un PNJ pourrait avoir. Donc, fondamentalement, si le monstre que vous essayez de tuer est faible contre votre arme, il meurt. Sinon, il ne sera pas affecté et, très probablement, très en colère.
Déclencheurs
Si vous avez porté une attention particulière à la définition de jeu JSON de mon article précédent, vous avez peut-être remarqué la définition du déclencheur trouvée sur les éléments de la scène. L'un d'eux en particulier consistait à mettre à jour le statut du jeu ( statusUpdate
). Au cours de la mise en œuvre, j'ai réalisé que le fait de le faire fonctionner comme une bascule offrait une liberté limitée. Vous voyez, dans la façon dont il a été implémenté (d'un point de vue idiomatique), vous pouviez définir un statut mais le désactiver n'était pas une option. Donc, à la place, j'ai remplacé cet effet déclencheur par deux nouveaux : addStatus
et removeStatus
. Ceux-ci vous permettront de définir exactement quand ces effets peuvent avoir lieu - le cas échéant. Je pense que c'est beaucoup plus facile à comprendre et à raisonner.
Cela signifie que les déclencheurs ressemblent maintenant à ceci :
"triggers": [ { "action": "pickup", "effect":{ "addStatus": "has light", "target": "game" } }, { "action": "drop", "effect": { "removeStatus": "has light", "target": "game" } } ]
Lorsque nous ramassons l'article, nous configurons un statut et lorsque nous le déposons, nous le supprimons. De cette façon, avoir plusieurs indicateurs d'état au niveau du jeu est tout à fait possible et facile à gérer.
La mise en oeuvre
Avec ces mises à jour à l'écart, nous pouvons commencer à couvrir la mise en œuvre réelle. D'un point de vue architectural, rien n'a changé ; nous construisons toujours une API REST qui contiendra la logique du moteur de jeu principal.
La pile technologique
Pour ce projet particulier, les modules que je vais utiliser sont les suivants :
Module | La description |
---|---|
Express.js | Évidemment, j'utiliserai Express comme base pour l'ensemble du moteur. |
Winston | Tout ce qui concerne la journalisation sera géré par Winston. |
Configuration | Chaque variable constante et dépendante de l'environnement sera gérée par le module config.js, ce qui simplifie grandement la tâche d'y accéder. |
Mangouste | Ce sera notre ORM. Je vais modéliser toutes les ressources à l'aide de Mongoose Models et les utiliser pour interagir directement avec la base de données. |
uuid | Nous devrons générer des identifiants uniques - ce module nous aidera dans cette tâche. |
Quant aux autres technologies utilisées en dehors de Node.js, nous avons MongoDB et Redis . J'aime utiliser Mongo en raison du manque de schéma requis. Ce simple fait me permet de réfléchir à mon code et aux formats de données, sans avoir à me soucier de la mise à jour de la structure de mes tables, des migrations de schéma ou des types de données en conflit.
En ce qui concerne Redis, j'ai tendance à l'utiliser comme système de support autant que possible dans mes projets et ce cas n'est pas différent. J'utiliserai Redis pour tout ce qui peut être considéré comme des informations volatiles, telles que les numéros de membre du groupe, les demandes de commande et d'autres types de données suffisamment petites et volatiles pour ne pas mériter un stockage permanent.
Je vais également utiliser la fonctionnalité d'expiration de clé de Redis pour gérer automatiquement certains aspects du flux (plus d'informations à ce sujet sous peu).
Définition de l'API
Avant de passer à l'interaction client-serveur et aux définitions de flux de données, je souhaite passer en revue les points de terminaison définis pour cette API. Ils ne sont pas si nombreux, la plupart du temps nous devons nous conformer aux principales fonctionnalités décrites dans la partie 1 :
Caractéristique | La description |
---|---|
Rejoindre un jeu | Un joueur pourra rejoindre une partie en spécifiant l'identifiant de la partie. |
Créer un nouveau jeu | Un joueur peut également créer une nouvelle instance de jeu. Le moteur doit renvoyer un ID, afin que d'autres puissent l'utiliser pour se joindre. |
Scène de retour | Cette fonctionnalité devrait renvoyer la scène actuelle où se trouve la fête. Fondamentalement, il renverra la description, avec toutes les informations associées (actions possibles, objets qu'elle contient, etc.). |
Interagissez avec la scène | Ce sera l'un des plus complexes, car il prendra une commande du client et effectuera cette action - des choses comme déplacer, pousser, prendre, regarder, lire, pour n'en nommer que quelques-unes. |
Vérifier l'inventaire | Bien que ce soit une façon d'interagir avec le jeu, cela n'est pas directement lié à la scène. Ainsi, vérifier l'inventaire de chaque joueur sera considéré comme une action différente. |
Enregistrer l'application cliente | Les actions ci-dessus nécessitent un client valide pour les exécuter. Ce point de terminaison vérifiera l'application cliente et renverra un ID client qui sera utilisé à des fins d'authentification lors des demandes ultérieures. |
La liste ci-dessus se traduit par la liste suivante de points de terminaison :
Verbe | Point final | La description |
---|---|---|
PUBLIER | /clients | Les applications clientes devront obtenir une clé d'ID client à l'aide de ce point de terminaison. |
PUBLIER | /games | De nouvelles instances de jeu sont créées à l'aide de ce point de terminaison par les applications clientes. |
PUBLIER | /games/:id | Une fois le jeu créé, ce point de terminaison permettra aux membres du groupe de le rejoindre et de commencer à jouer. |
AVOIR | /games/:id/:playername | Ce point de terminaison renverra l'état actuel du jeu pour un joueur particulier. |
PUBLIER | /games/:id/:playername/commands | Enfin, avec ce point de terminaison, l'application cliente pourra soumettre des commandes (en d'autres termes, ce point de terminaison servira à jouer). |
Permettez-moi d'aborder un peu plus en détail certains des concepts que j'ai décrits dans la liste précédente.
Applications clientes
Les applications clientes devront s'enregistrer dans le système pour commencer à l'utiliser. Tous les points de terminaison (à l'exception du premier de la liste) sont sécurisés et nécessiteront l'envoi d'une clé d'application valide avec la demande. Afin d'obtenir cette clé, les applications clientes doivent simplement en demander une. Une fois fournis, ils dureront aussi longtemps qu'ils seront utilisés ou expireront après un mois de non-utilisation. Ce comportement est contrôlé en stockant la clé dans Redis et en lui définissant une durée de vie d'un mois.
Instance de jeu
Créer un nouveau jeu signifie essentiellement créer une nouvelle instance d'un jeu particulier. Cette nouvelle instance contiendra une copie de toutes les scènes et de leur contenu. Toute modification apportée au jeu n'affectera que le groupe. De cette façon, de nombreux groupes peuvent jouer au même jeu de leur propre manière.
État de jeu du joueur
Ceci est similaire au précédent, mais unique pour chaque joueur. Alors que l'instance de jeu contient l'état du jeu pour l'ensemble du groupe, l'état du jeu du joueur contient l'état actuel d'un joueur particulier. Principalement, cela contient l'inventaire, la position, la scène actuelle et les HP (points de santé).
Commandes du joueur
Une fois que tout est configuré et que l'application cliente s'est enregistrée et a rejoint une partie, elle peut commencer à envoyer des commandes. Les commandes implémentées dans cette version du moteur incluent : move
, look
, pickup
et attack
.
- La commande de
move
vous permettra de parcourir la carte. Vous serez capable de spécifier la direction vers laquelle vous voulez vous déplacer et le moteur vous fera connaître le résultat. Si vous jetez un coup d'œil à la partie 1, vous pouvez voir l'approche que j'ai adoptée pour gérer les cartes. (En bref, la carte est représentée sous forme de graphique, où chaque nœud représente une pièce ou une scène et n'est connecté qu'aux autres nœuds qui représentent des pièces adjacentes.)
La distance entre les nœuds est également présente dans la représentation et couplée à la vitesse standard d'un joueur ; passer d'une pièce à l'autre n'est peut-être pas aussi simple que d'énoncer votre commande, mais vous devrez également parcourir la distance. En pratique, cela signifie que passer d'une pièce à l'autre peut nécessiter plusieurs commandes de déplacement). L'autre aspect intéressant de cette commande vient du fait que ce moteur est destiné à prendre en charge les parties multijoueurs, et la partie ne peut pas être divisée (du moins pas pour le moment).
Par conséquent, la solution pour cela est similaire à un système de vote : chaque membre du groupe enverra une demande de commande de déplacement quand il le souhaite. Une fois que plus de la moitié d'entre eux l'ont fait, la direction la plus demandée sera utilisée. - le
look
est assez différent du mouvement. Il permet au joueur de spécifier une direction, un objet ou un PNJ qu'il souhaite inspecter. La logique clé derrière cette commande entre en ligne de compte lorsque vous pensez aux descriptions dépendant du statut.
Par exemple, disons que vous entrez dans une nouvelle pièce, mais qu'il fait complètement noir (vous ne voyez rien), et que vous avancez en l'ignorant. Quelques pièces plus tard, vous ramassez une torche allumée sur un mur. Alors maintenant, vous pouvez revenir en arrière et réinspecter cette pièce sombre. Puisque vous avez récupéré la torche, vous pouvez maintenant voir à l'intérieur et interagir avec tous les objets et PNJ que vous y trouverez.
Ceci est réalisé en maintenant un ensemble d'attributs de statut à l'échelle du jeu et spécifiques au joueur et en permettant au créateur du jeu de spécifier plusieurs descriptions pour nos éléments dépendant du statut dans le fichier JSON. Chaque description est alors équipée d'un texte par défaut et d'un ensemble de conditionnels, en fonction de l'état actuel. Ces derniers sont facultatifs ; la seule obligatoire est la valeur par défaut.
De plus, cette commande a une version abrégée pourlook at room: look around
; c'est parce que les joueurs essaieront d'inspecter une pièce très souvent, donc fournir une commande abrégée (ou alias) plus facile à taper a beaucoup de sens. - La commande de
pickup
joue un rôle très important pour le gameplay. Cette commande s'occupe d'ajouter des objets dans l'inventaire des joueurs ou dans leurs mains (s'ils sont libres). Afin de comprendre où chaque élément est censé être stocké, leur définition a une propriété "destination" qui précise s'il est destiné à l'inventaire ou aux mains du joueur. Tout ce qui est récupéré avec succès de la scène en est ensuite supprimé, mettant à jour la version du jeu de l'instance de jeu. - La commande
use
vous permettra d'affecter l'environnement en utilisant les objets de votre inventaire. Par exemple, ramasser une clé dans une pièce vous permettra de l'utiliser pour ouvrir une porte verrouillée dans une autre pièce. - Il existe une commande spéciale, qui n'est pas liée au gameplay, mais plutôt une commande d'assistance destinée à obtenir des informations particulières, telles que l'ID de jeu actuel ou le nom du joueur. Cette commande s'appelle get et les joueurs peuvent l'utiliser pour interroger le moteur du jeu. Par exemple : get gameid .
- Enfin, la dernière commande implémentée pour cette version du moteur est la commande
attack
. J'ai déjà couvert celui-ci; En gros, vous devrez spécifier votre cible et l'arme avec laquelle vous l'attaquez. De cette façon, le système pourra vérifier les faiblesses de la cible et déterminer le résultat de votre attaque.
Interaction client-moteur
Afin de comprendre comment utiliser les points de terminaison énumérés ci-dessus, laissez-moi vous montrer comment tout client potentiel peut interagir avec notre nouvelle API.
Étape | La description |
---|---|
Inscrire un client | Tout d'abord, l'application cliente doit demander une clé API pour pouvoir accéder à tous les autres points de terminaison. Pour obtenir cette clé, il doit s'inscrire sur notre plateforme. Le seul paramètre à fournir est le nom de l'application, c'est tout. |
Créer un jeu | Une fois la clé API obtenue, la première chose à faire (en supposant qu'il s'agit d'une toute nouvelle interaction) est de créer une toute nouvelle instance de jeu. Pensez-y de cette façon : le fichier JSON que j'ai créé dans mon dernier message contient la définition du jeu, mais nous devons en créer une instance juste pour vous et votre groupe (pensez aux classes et aux objets, même chose). Vous pouvez faire ce que vous voulez avec cette instance, et cela n'affectera pas les autres parties. |
Rejoignez le jeu | Après avoir créé le jeu, vous obtiendrez un ID de jeu du moteur. Vous pouvez ensuite utiliser cet ID de jeu pour rejoindre l'instance en utilisant votre nom d'utilisateur unique. À moins que vous ne rejoigniez le jeu, vous ne pouvez pas jouer, car rejoindre le jeu créera également une instance d'état de jeu pour vous seul. C'est là que votre inventaire, votre position et vos statistiques de base sont sauvegardés par rapport au jeu auquel vous jouez. Vous pourriez potentiellement jouer à plusieurs jeux en même temps, et dans chacun d'eux avoir des états indépendants. |
Envoyer des commandes | En d'autres termes : jouez le jeu. La dernière étape consiste à commencer à envoyer des commandes. La quantité de commandes disponibles était déjà couverte, et elle peut être facilement étendue (plus à ce sujet dans un instant). Chaque fois que vous envoyez une commande, le jeu renverra le nouvel état du jeu pour que votre client mette à jour votre vue en conséquence. |
Mettons nos mains dans le cambouis
J'ai passé en revue autant de conception que possible, dans l'espoir que ces informations vous aideront à comprendre la partie suivante, alors entrons dans les détails du moteur de jeu.
Note : je ne vais pas vous montrer le code complet dans cet article car il est assez gros et tout n'est pas intéressant. Au lieu de cela, je montrerai les parties les plus pertinentes et un lien vers le référentiel complet au cas où vous voudriez plus de détails.
Le fichier principal
Tout d'abord, il s'agit d'un projet Express et son code passe-partout basé a été généré à l'aide du propre générateur d'Express, de sorte que le fichier app.js devrait vous être familier. Je veux juste passer en revue deux ajustements que j'aime faire sur ce code pour simplifier mon travail.
Tout d'abord, j'ajoute l'extrait de code suivant pour automatiser l'inclusion de nouveaux fichiers de route :
const requireDir = require("require-dir") const routes = requireDir("./routes") //... Object.keys(routes).forEach( (file) => { let cnt = routes[file] app.use('/' + file, cnt) })
C'est vraiment assez simple, mais cela élimine le besoin d'exiger manuellement chaque fichier de route que vous créez à l'avenir. Soit dit en passant, require-dir
est un module simple qui prend en charge la demande automatique de chaque fichier dans un dossier. C'est ça.
L'autre changement que j'aime faire est de modifier un peu mon gestionnaire d'erreurs. Je devrais vraiment commencer à utiliser quelque chose de plus robuste, mais pour les besoins actuels, j'ai l'impression que cela fait le travail :
// 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); });
Le code ci-dessus prend en charge les différents types de messages d'erreur que nous pourrions avoir à traiter - soit des objets complets, des objets d'erreur réels lancés par Javascript ou de simples messages d'erreur sans autre contexte. Ce code prendra tout et le formatera dans un format standard.
Gestion des commandes
C'est un autre de ces aspects du moteur qui devait être facile à étendre. Dans un projet comme celui-ci, il est tout à fait logique de supposer que de nouvelles commandes apparaîtront à l'avenir. S'il y a quelque chose que vous voulez éviter, ce serait probablement d'éviter d'apporter des modifications au code de base lorsque vous essayez d'ajouter quelque chose de nouveau trois ou quatre mois plus tard.
Aucune quantité de commentaires de code ne facilitera la tâche de modifier le code auquel vous n'avez pas touché (ou même pensé) depuis plusieurs mois, donc la priorité est d'éviter autant de changements que possible. Heureusement pour nous, il existe quelques modèles que nous pouvons mettre en œuvre pour résoudre ce problème. En particulier, j'ai utilisé un mélange des modèles Command et Factory.
J'ai essentiellement encapsulé le comportement de chaque commande dans une seule classe qui hérite d'une classe BaseCommand
qui contient le code générique de toutes les commandes. En même temps, j'ai ajouté un module CommandParser
qui récupère la chaîne envoyée par le client et renvoie la commande réelle à exécuter.
L'analyseur est très simple puisque toutes les commandes implémentées ont maintenant la commande réelle quant à leur premier mot (c'est-à-dire "se déplacer vers le nord", "prendre un couteau", etc.). Il suffit de diviser la chaîne et d'obtenir la première partie :
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 } } }
Remarque : J'utilise à nouveau le module require-dir
pour simplifier l'inclusion de toutes les classes de commandes existantes et nouvelles. Je l'ajoute simplement au dossier et l'ensemble du système est capable de le récupérer et de l'utiliser.
Cela étant dit, il existe de nombreuses façons d'améliorer cela; par exemple, être capable d'ajouter la prise en charge des synonymes pour nos commandes serait une fonctionnalité intéressante (donc dire "se déplacer vers le nord", "aller vers le nord" ou même "marcher vers le nord" signifierait la même chose). C'est quelque chose que nous pourrions centraliser dans cette classe et affecter toutes les commandes en même temps.
Je n'entrerai dans les détails d'aucune des commandes car, encore une fois, c'est trop de code à montrer ici, mais vous pouvez voir dans le code de route suivant comment j'ai réussi à généraliser cette gestion des commandes existantes (et futures):
/** 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) }) })
Toutes les commandes ne nécessitent que la méthode run
- tout le reste est supplémentaire et destiné à un usage interne.
Je vous encourage à aller revoir l'intégralité du code source (même à le télécharger et à jouer avec si vous le souhaitez !). Dans la prochaine partie de cette série, je vais vous montrer l'implémentation réelle du client et l'interaction de cette API.
Réflexions finales
Je n'ai peut-être pas couvert une grande partie de mon code ici, mais j'espère toujours que l'article a été utile pour vous montrer comment j'aborde les projets, même après la phase de conception initiale. J'ai l'impression que beaucoup de gens essaient de commencer à coder comme première réponse à une nouvelle idée et cela peut parfois finir par décourager un développeur car il n'y a pas de véritable plan défini ni d'objectifs à atteindre - à part avoir le produit final prêt ( et c'est une étape trop importante à franchir dès le premier jour). Encore une fois, mon espoir avec ces articles est de partager une manière différente de travailler en solo (ou en petit groupe) sur de grands projets.
J'espère que vous avez apprécié la lecture ! N'hésitez pas à laisser un commentaire ci-dessous avec tout type de suggestions ou de recommandations, j'aimerais lire ce que vous pensez et si vous êtes impatient de commencer à tester l'API avec votre propre code côté client.
A la prochaine !
Autres parties de cette série
- Partie 1 : L'introduction
- Partie 3 : Création du client Terminal
- Partie 4 : Ajouter le chat dans notre jeu