Fermer

octobre 23, 2019

Conception de serveurs de moteur de jeu (2e partie)


À propos de l'auteur

Fernando Doglio travaille depuis 13 ans en tant que développeur Web et en tant que responsable technique depuis quatre ans. À cette époque, il adorait les…
Plus d'informations sur
Fernando
Doglio

Bienvenue à la deuxième partie de cette série. Dans la première partie nous avons présenté l'architecture d'une plate-forme et d'une application clientes basées sur Node.js qui permettront aux utilisateurs de définir et de jouer leurs propres aventures au format texte en tant que groupe. Cette fois-ci, nous allons couvrir la création de l’un des modules définis par Fernando la dernière fois (le moteur de jeu) et nous nous concentrerons également sur le processus de conception afin de mieux comprendre ce qui doit se passer avant de commencer à coder vos fichiers. projets de loisirs personnels.

Après un examen approfondi et la mise en œuvre effective du module, certaines des définitions que j’ai établies au cours de 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 enthousiaste qui rêve d’un produit idéal mais qui doit faire preuve de retenue de la part de l’équipe de développement.

Une fois les fonctionnalités implémentées et testées, votre équipe commencera à remarquer que certaines les caractéristiques peuvent différer du plan initial, et c'est très bien. Il suffit de notifier, d'ajuster et de continuer. Alors, sans plus tarder, permettez-moi d’expliquer d’abord ce qui a changé par rapport au plan initial .

Mécanique du combat

C’est probablement le changement le plus important par rapport au plan initial. Je sais que j'ai dit que j'allais utiliser une implémentation D & D dans laquelle chaque PC et NPC impliqués obtiendraient une valeur d'initiative et ensuite, 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 conserver le statut entre les appels.

Je vais donc plutôt profiter de la Mécanique simplifiée de REST et l'utiliser pour simplifier notre mécanique de combat. La version implémentée sera basée sur les joueurs et non sur les parties, et permettra aux joueurs d’attaquer les PNJ (personnages non-joueurs). Si leur attaque réussit, les PNJ seront tués ou bien ils attaqueront en infligeant des dégâts ou en tuant le joueur.

Le succès ou l'échec de l'attaque sera déterminé par le type d'arme utilisée et les faiblesses qu'un NPC pourrait avoir. Donc, fondamentalement, si le monstre que vous essayez de tuer est faible contre votre arme, il meurt. Sinon, ça ne sera pas affecté et – très probablement – très en colère.

Déclencheurs

Si vous avez prêté une attention particulière à la définition du jeu JSON de mon article précédent vous avez peut-être remarqué la présence de la gâchette. définition trouvée sur les éléments de la scène. Un cas particulier impliquait la mise à jour du statut du jeu ( statusUpdate ). Lors de la mise en œuvre, je me suis rendu compte qu’elle fonctionnait comme une bascule pour une liberté limitée. Vous voyez, dans la manière dont elle a été mise en œuvre (d’un point de vue idiomatique), vous avez pu définir un statut, mais le désactiver n’était pas une option. Au lieu de cela, j’ai donc remplacé cet effet de déclenchement par deux nouveaux: addStatus et removeStatus . Cela vous permettra de définir exactement quand ces effets peuvent avoir lieu – voire pas du tout. Je pense que c'est beaucoup plus facile à comprendre et à raisonner.

Cela signifie que les déclencheurs ressemblent maintenant à ceci:

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

Lors de la sélection de l'article, nous établissons un statut, et quand on le laisse tomber, on l'enlève. De cette façon, il est tout à fait possible et facile de gérer plusieurs indicateurs d’état au niveau du jeu.

La mise en oeuvre

Une fois ces mises à jour terminées, nous pouvons commencer à couvrir la mise en œuvre réelle. D'un point de vue architectural, rien n'a changé. nous sommes toujours en train de construire une API REST qui contiendra la logique du moteur de jeu principal.

The Tech Stack

Pour ce projet particulier, les modules que je vais utiliser sont les suivants:

Module [19659020] Description
Express.js Évidemment, j'utiliserai Express comme base du moteur complet
Winston Tout ce qui concerne l’exploitation forestière sera traité par Winston.
Config Toutes les variables constantes et dépendantes de l'environnement seront traitées par le module config.js, ce qui simplifie grandement la tâche d'accès.
Mongoose Ce sera notre ORM. Je modélise toutes les ressources à l'aide de modèles Mongoose et les utilise 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.

Outre les technologies utilisées, 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 à m'inquiéter 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 je peux dans mes projets et cette affaire n’est pas différente. J'utiliserai Redis pour tout ce qui peut être considéré comme une information volatile, telle que les numéros de membre du parti, les demandes de commande et d'autres types de données suffisamment petites et suffisamment volatiles pour ne pas mériter un stockage permanent.

utiliser la fonctionnalité d'expiration de clé de Redis pour gérer automatiquement certains aspects du flux (plus de détails prochainement).

Définition de l'API

Avant de passer aux définitions d'interaction client-serveur et de flux de données, je souhaite passer en revue les points finaux définis. pour cette API. Ils ne sont pas nombreux, mais nous devons surtout respecter les principales caractéristiques décrites dans Partie 1 :

Caractéristiques Description
Rejoindre une partie Un joueur sera en mesure de rejoindre une partie en spécifiant l'ID du jeu.
Créer une nouvelle partie Un joueur peut également créer une nouvelle instance de jeu. Le moteur doit renvoyer un identifiant afin que d'autres puissent l'utiliser pour se joindre.
Renvoi de la scène Cette fonctionnalité doit renvoyer la scène actuelle où se trouve la partie. Fondamentalement, elle renverra la description, avec toutes les informations associées (actions possibles, objets qu'elle contient, etc.).
Interagissez avec la scène Cette opération sera l'une des plus complexes, car prendra une commande du client et exécutera cette action – déplacer, pousser, prendre, regarder, lire, pour n'en nommer que quelques-uns.
Vérifier l'inventaire Même s'il s'agit d'un moyen d'interagir avec le jeu, il ne concerne pas directement la scène. Ainsi, la vérification de l'inventaire de chaque joueur sera considérée comme une action différente.
Enregistrement de l'application client Les actions ci-dessus nécessitent un client valide pour les exécuter. Ce noeud final vérifiera l'application client et renverra un ID client qui sera utilisé à des fins d'authentification lors de requêtes ultérieures.

La liste ci-dessus se traduit par la liste suivante de noeuds finaux:

Verb Endpoint Description [19659038] POST / clients Les applications clientes devront obtenir une clé d'identification client à l'aide de ce terminal.
POST / games Nouveau les instances clientes créent des instances de jeu utilisant ce noeud final.
POST / games /: id Une fois le jeu créé, ce noeud final permettra aux membres du groupe de le rejoindre et de démarrer. jouer.
GET / games /: id /: nom_du_fichier Ce noeud final renvoie l'état actuel du jeu pour un joueur donné.
POST / games /: id /: nom_du_fichier / commandes Enfin, avec ce noeud final, l'application cliente pourra pour soumettre des commandes (en d'autres termes, ce noeud final sera utilisé pour jouer).

Permettez-moi d'entrer un peu plus en détail sur certains des concepts que j'ai décrits dans la liste précédente.

Applications clientes

Le client les applications devront s'inscrire 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. Pour obtenir cette clé, les applications client doivent simplement en demander une. Une fois fournis, ils dureront aussi longtemps qu'ils seront utilisés ou expireront après un mois d'inutilisation. Ce comportement est contrôlé en stockant la clé dans Redis et en y fixant un TTL long d'un mois.

Instance de jeu

La ​​création d'un nouveau jeu consiste 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 la partie. Ainsi, de nombreux groupes peuvent jouer au même jeu à leur manière.

État du jeu du joueur

Cet état est similaire au précédent, mais propre à chaque joueur. Bien que l’instance de jeu conserve l’état de jeu pour l’ensemble du groupe, l’état de jeu du joueur conserve le statut actuel du joueur en question. Il contient principalement l'inventaire, la position, la scène actuelle et les points de santé.

Commandes du joueur

Une fois que tout est configuré et que l'application cliente s'est enregistrée et a rejoint un jeu, elle peut commencer à envoyer des commandes. Les commandes implémentées dans cette version du moteur incluent: move look prise en charge et attaque .

  • La La commande move vous permettra de parcourir la carte. Vous pourrez spécifier la direction dans laquelle vous souhaitez vous déplacer et le moteur vous indiquera le résultat. Si vous jetez un coup d'œil rapide à Partie 1 vous pouvez voir l'approche que j'ai adoptée pour gérer les cartes. (En résumé, la carte est représentée sous forme graphique, chaque nœud représentant une pièce ou une scène et n'est connecté qu'à d'autres nœuds représentant des pièces adjacentes.)

    La ​​distance entre les nœuds est également présente dans la représentation et couplée au vitesse standard d'un joueur; aller de pièce en pièce n’est peut-être pas aussi simple que d’énoncer votre commande, mais vous devrez aussi traverser 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 provient du fait que ce moteur est conçu pour prendre en charge les parties multijoueurs, et que la partie ne peut pas être scindée (du moins pas pour le moment).

    Par conséquent, la solution proposée est la suivante: un système de vote: chaque membre du parti enverra une demande de commande de déplacement à tout moment. Une fois que plus de la moitié d'entre eux l'auront fait, la direction la plus demandée sera utilisée.

  • look est assez différent du déménagement. Il permet au joueur de spécifier une direction, un objet ou un PNJ à inspecter. La logique qui sous-tend cette commande entre en ligne de compte lorsque vous réfléchissez aux descriptions dépendantes du statut.

    Par exemple, supposons que vous entrez dans une nouvelle pièce, mais il fait complètement noir (vous ne voyez rien) et vous bougez transmettre tout en l’ignorant. Quelques chambres plus tard, vous prenez une torche allumée dans un mur. Alors maintenant, vous pouvez revenir en arrière et inspecter de nouveau cette pièce sombre. Depuis que vous avez pris le flambeau, vous pouvez maintenant voir l’intérieur de celui-ci et être en mesure d’interagir avec n’importe quel objet ou PNJ que vous y trouverez.

    Pour ce faire, vous devez conserver un ensemble de jeux large et spécifique au joueur. attributs de statut et permettant au créateur du jeu de spécifier plusieurs descriptions pour nos éléments dépendants du statut dans le fichier JSON. Chaque description est ensuite équipée d'un texte par défaut et d'un ensemble de conditions, en fonction de l'état actuel. Ces derniers sont optionnels. la seule valeur obligatoire est la valeur par défaut.

    De plus, cette commande a une version abrégée pour look at room: look around ; c'est parce que les joueurs essaieront d'inspecter très souvent une salle, aussi fournir une commande abrégée (ou un alias) plus facile à saisir est très utile.

  • La commande pickup joue un rôle très important pour le gameplay. Cette commande permet d’ajouter des objets dans l’inventaire des joueurs ou dans leurs mains (s’ils sont libres). Afin de comprendre où chaque objet doit être stocké, sa définition a une propriété «destination» qui spécifie si elle est destinée à l’inventaire ou aux mains du joueur. Tout ce qui est capté avec succès dans la scène en est ensuite retiré, ce qui met à jour la version du jeu.
  • La commande use vous permettra d’affecter l’environnement à l’aide d’objets de votre inventaire. Par exemple, ramasser une clé dans une pièce vous permettra de l’ouvrir pour ouvrir une porte verrouillée dans une autre pièce.
  • Il existe une commande spéciale, qui n’est pas liée au jeu, mais à une commande d’aide destinée à obtenir des informations particulières, telles que l'identifiant de jeu actuel ou le nom du joueur. Cette commande s'appelle get et les joueurs peuvent l'utiliser pour interroger le moteur de jeu. Par exemple: get gameid .
  • Enfin, la dernière commande implémentée pour cette version du moteur est la commande d'attaque . J'ai déjà couvert celui-ci; En gros, vous devez spécifier votre cible et l’arme avec laquelle vous l’attaquez. Ainsi, le système sera en mesure de vérifier les faiblesses de la cible et de déterminer le résultat de votre attaque.

Interaction client-moteur

Pour comprendre comment utiliser les points de terminaison énumérés ci-dessus, laissez-moi vous montrer comment -be-client peut interagir avec notre nouvelle API.

Étape Description
Enregistrer le 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 être enregistré 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'agisse d'une toute nouvelle interaction) consiste à créer une marque. 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 rien que pour vous et votre parti (pensez classes et objets, même transaction). Vous pouvez faire ce que vous voulez avec cette instance et cela n’affectera pas les autres parties.
Rejoignez le jeu Une fois le jeu créé, vous obtiendrez un ID de jeu à partir du moteur. Vous pouvez ensuite utiliser cet ID de jeu pour rejoindre l'instance à l'aide de votre nom d'utilisateur unique. Si vous ne rejoignez pas le jeu, vous ne pourrez pas jouer, car rejoindre le jeu créera également une instance d'état du jeu pour vous seul. C’est là que votre inventaire, votre position et vos statistiques de base sont enregistrés par rapport au jeu auquel vous jouez. Vous pourriez potentiellement jouer à plusieurs jeux en même temps et avoir chacun des états indépendants.
Envoi de commandes En d'autres termes: jouez le jeu. La dernière étape consiste à commencer à envoyer des commandes. Le nombre de commandes disponibles était déjà couvert et il peut être facilement étendu (plus de détails dans un peu). Chaque fois que vous envoyez une commande, le jeu renverra le nouvel état du jeu afin que votre client puisse mettre à jour votre vue en conséquence.

Obtenons la main sur nous

J'ai passé en revue le plus de concept possible, dans l'espoir que cette information vous aidera à comprendre la partie suivante, nous allons donc entrer dans les détails du moteur du jeu.

Note : Je ne vous montrerai pas le code complet dans cet article car c'est assez grand 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: c’est un projet Express et que son code passe-partout a été généré à l'aide du propre générateur d'Express, le fichier app.js devrait vous être familier. Je veux juste passer en revue deux ajustements que j'aime bien faire sur ce code pour simplifier mon travail.

Premièrement, j'ajoute le fragment de code suivant pour automatiser l'inclusion de nouveaux fichiers de route:

 const require requireDir = require ("require- dir ")
const routes = requireDir ("./ routes")

// ...

Object.keys (routes) .forEach ((fichier) => {
    let cnt = routes [file]
    app.use ('/' + fichier, cnt)
}) 

C’est assez simple, mais vous évitez de demander manuellement à chaque fichier de route que vous créerez ultérieurement. À propos, require-dir est un module simple qui prend en charge l’autoexigence de tous les fichiers d’un dossier.

L’autre changement que j’aime faire est de modifier un peu mon gestionnaire d’erreur. Je devrais vraiment commencer à utiliser quelque chose de plus robuste, mais pour les besoins actuels, j’ai l’impression que cela facilite le travail:

 // gestionnaire des erreurs
app.use (function (err, req, res, next) {
  // rendre la page d'erreur
  if (typeof err === "chaîne") {
    err = {
      statut: 500,
      message: err
    }
  }
  res.status (err.status || 500);
  let errorObj = {
    erreur: vrai,
    msg: err.message,
    errCode: err.status || 500
  }
  if (err.trace) {
    errorObj.trace = err.trace
  }

  res.json (errorObj);

Le code ci-dessus traite les différents types de messages d'erreur que nous pourrions avoir à traiter: objets complets, objets d'erreur générés par Javascript ou messages d'erreur simples sans autre contexte. Ce code prendra tout cela et le formatera dans un format standard.

Manipulation des commandes

Voici un autre aspect 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. Si vous souhaitez éviter quelque chose, vous éviterez probablement de modifier le code de base lorsque vous essayez d'ajouter quelque chose de nouveau dans les trois ou quatre mois à venir.

Aucune quantité de commentaires de code ne nécessitera la modification du code. vous n'avez pas touché (ou même pensé) depuis plusieurs mois, alors la priorité est d'éviter autant que possible les changements. Heureusement pour nous, il existe quelques modèles que nous pouvons appliquer pour résoudre ce problème. En particulier, j’ai utilisé un mélange de modèles Command et Factory.

En gros, j’encapsulais le comportement de chaque commande dans une classe unique qui hérite d’une classe BaseCommand contenant 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 à exécuter.

L'analyseur est très simple, car toutes les commandes implémentées ont désormais la commande réelle. Pour leur premier mot ("déplacer le nord", "ramasser un couteau", etc.), il suffit de séparer la chaîne et d'obtenir la première partie:

 const require_dir = require ("require-dir")
const validCommands = requireDir ('commandes /.')

classe CommandParser {


    constructeur (commande) {
        this.command = commande
    }


    normalizeAction (strAct) {
        strAct = strAct.toLowerCase (). split ("") [0]
        retourne la strAct
    }


    verifyCommand () {
        if (! this.command) renvoie false
        if (! this.command.action) renvoie false
        if (! this.command.context) renvoie false

        let action = this.normalizeAction (this.command.action)

        if (validCommands [action]) {
            retourne les commandes valides [action]
        }
        retourne faux
    }

    parse () {
        let validCommand = this.verifyCommand ()
        if (validCommand) {
            let cmdObj = new validCommand (this.command)
            retourne cmdObj
        } autre {
            retourne faux
        }
    }
} 

Note : J'utilise à nouveau le module require-dir pour simplifier l'inclusion de toute classe de commande existante ou nouvelle. Je l'ajoute simplement au dossier et le système tout entier est capable de le récupérer et de l'utiliser.

Cela dit, il existe de nombreuses façons d'améliorer cela; par exemple, il serait très utile d’ajouter un support synonyme à nos commandes (ainsi, «se déplacer vers le nord», «se rendre au nord» ou même «se déplacer au nord» aurait le même sens). 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 des commandes car, encore une fois, c’est trop de code à afficher ici, mais vous pouvez voir. dans le code de route suivant, j'ai réussi à généraliser le traitement des commandes existantes (et futures):

 / **
Interaction avec une scène particulière
* /
router.post ('/: id /: nom de lecture /: scene', fonction (req, res, next) {

    let command = req.body
    command.context = {
        gameId: req.params.id,
        nom de lecture: req.params.playername,
    }

    let parser = new CommandParser (command)

    let commandObj = parser.parse () // renvoie l'instance de commande
    if (! commandObj) renvoie next ({// traitement des erreurs
        statut: 400,
          errorCode: config.get ("errorCodes.invalidCommand"),
        message: "commande inconnue"
    })

    commandObj.run ((err, result) => {// exécuter la commande
        if (err) retourne ensuite (err)

        res.json (résultat)
    })

}) 

Toutes les commandes ne requièrent que la méthode de l'exécution – tout autre élément est en sus et destiné à un usage interne.

Je vous encourage à consulter le code source entier (1965). même le télécharger et jouer avec si vous voulez!). Dans la partie suivante de cette série, je vous montrerai la mise en œuvre et l'interaction réelles de cette API avec le client.

Closing Pensées

Je n'ai peut-être pas couvert beaucoup de mon code ici, mais j'espère quand même que l'article a été utile pour vous montrer comment j’attaque des projets – même après la phase de conception initiale. Je pense que beaucoup de gens essaient de commencer à coder comme première réponse à une nouvelle idée et que cela peut parfois décourager les développeurs, car aucun plan ni aucun objectif à atteindre ne sont réellement définis, à part le fait de préparer le produit final ( et c’est une étape trop importante à franchir dès le premier jour). Encore une fois, j’espère que ces articles permettront de partager une façon différente de travailler en solo (ou au sein d’un petit groupe) sur de grands projets.

J'espère que vous avez apprécié la lecture! S'il vous plaît n'hésitez pas à laisser un commentaire ci-dessous avec tout type de suggestions ou 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.

On se revoit au prochain!

 Éditorial éclatant (dm, yk, il)




Source link