Pour ceux qui n'auraient pas suivi le début de ce tutoriel, je vous conseille de le lire juste ici : https://blog.nicolas.brondin-bernard.com/tutoriel-creer-un-jeu-d-aventure-textuel-partie-1-parser-syntaxique/

La dernière fois nous avons créé notre parseur syntaxique basique afin de valider les phrases entrées par le joueur et de séparer chaque mot afin de les classifier.

Le but du tutoriel d'aujourd'hui va être de faire le le lien entre ce parseur, le terminal et le moteur du jeu. La partie "moteur" est pour l'instant très simple car cela fera l'objet du tutoriel suivant !

Partie 2 : Comprendre le joueur

Gérer les entrées clavier

Pour l'instant notre parseur ne fonctionne qu'en lui passant des phrases écrites à l'avance, mais pour un jeu textuel il faut avant tout que le joueur puisse écrire du texte !

Le but du projet est d'arriver à avoir un code suffisamment modulaire et j'ai donc décidé de créer une classe spéciale pour la gestion du clavier : l'InputManager.

//InputManager.js
const readline = require('readline');

class InputManager {
    constructor(on_command){
        this.on_command = on_command;
    }

    request_input(){
        const rl = readline.createInterface({
            input: process.stdin,
            output: process.stdout
        });
        rl.on('line', (input) => {
            rl.close();
            this.on_command(input);
        });
    }

}

module.exports = InputManager;

Cette classe va être initialisé par le moteur du jeu en lui passant un callback pour chaque nouvelle commande entrée par le joueur, et comme c'est le jeu qui va décider quand laisser la parole au joueur, on invoque la lecture du terminal grâce à la méthode request_input().

Le fait que cette classe soit séparée permet par exemple de la remplacer par une autre classe ayant la même interface mais fonctionnant sur des commandes vocales !

La version vocale ne sera pas couverte par le tuto évidemment !

Le gestionnaire de commandes

Pour l'instant on sait écouter les entrées utilisateurs, les parser pour les annoter et vérifier leur syntaxe, mais il nous faut les transformer en actions concrètes !

Le but du gestionnaire de commande va être de définir le rôle de chaque token de la phrase du joueur et de déterminer quelles sont les actions à exécuter.

En l'occurence dans cette première version, le token "noun" va servir à sélectionner l'objet à cibler et le token "verb" va servir à effectuer une action dessus. Pour l'instant les autres tokens comme les prepositions et les articles vont être ignorés et servent juste à donner de la flexibilité au joueur dans sa rédaction.

//CommandManager.js

class CommandManager {
    constructor(game_instance){
        this.game = game_instance;
    }

    process_command(tokens){
        let verb = tokens.find(function(token){
            return token.type === 'verb';
        });
        let noun = tokens.find(function(token){
            return token.type === 'noun';
        });

        if(noun){
            this.game.target_item(noun.value);
        }

        if(this.game.check_target()){
            switch(verb.value) {
                case "lire" : {this.game.read(); break;}
                default: {throw Error('Unknown command');}
            }
        }
    }

}

module.exports = CommandManager;

Vous aurez noté que si la phrase n'inclut pas de token "noun", on exécute quand même la commande, mais cette fois-ci en utilisant le dernier objet ciblé, ce qui nous permettra par exemple d'appeler les commandes "lire lettre" puis "relire" sans avoir à spécifier l'objet à chaque fois.

Le moteur de jeu basique

Ici notre moteur va nous servir à trois choses principales :

  • Relier tous les composants précédents entre eux
  • Exposer une représentation du monde très basique (ici une liste d'objets)
  • Commencer à écrire la logique derrière les commandes
//Game.js

const Parser = require('./Parser'),
InputManager = require('./InputManager'),
CommandManager = require('./CommandManager');

class Game {
    constructor (){
        this.parser = new Parser();
        this.command_manager = new CommandManager(this);
        this.input_manager = new InputManager(this.command_listener);
        this.input_manager.request_input();

        this.target;
        this.world = [
            {name: "lettre", text:"J'ai pris les clés et fermé la porte, si tu veux sortir trouve le double, je ne sais plus où il est !"}
        ];
    }


    command_listener = (line) => {
        try {
            let tokens = this.parser.parseText(line);
            this.command_manager.process_command(tokens);
        } catch(e){
            console.log("Je n'ai pas compris cette commande.");
            this.input_manager.request_input();
        }
    }
}

module.exports = Game;

Lors de l'instanciation du Game, on instancie aussi le Parser, l'InputManager et le Command Manager. En passant la fonction "command_listener" en callback de l'InputManager, on fait le lien entre le parser et le command_manager qui vont traiter les données l'un après l'autre.

//Game.js

    target_item(target_name){
        let target_item = this.world.find(function(item){
            return item.name.toLowerCase() === target_name.toLowerCase();
        })
        if(target_item){
            this.target = target_item;
        } else {
            console.log("Je ne sais pas de quel objet vous parlez.");
        }
    }

    check_target(){
        if(this.target){
            return true;
        } else {
            console.log("Vous devez spécifier un objet");
        }
    }

Ensuite on vient ajouter les deux fonction qui ont pour but de gérer la cible actuelle du joueur, ici on ne parle pas de cible au sens "combattif" du terme, mais l'on vient simplement mettre un "pointeur" sur l'objet que regarde le joueur à un instant T.

A terme la méthode target_item sera beaucoup plus complexe car elle devra être capable de chercher aussi dans l'inventaire du joueur, et de trouver les objets par d'autres mots-clés que leur nom exacte. Par exemple "Lettre de maman" devra pouvoir être ciblé en écrivant juste "lettre" si il n'y a pas d'ambiguité dans les objets possibles.

//Game.js
    read(){
        if(this.target.text){
            console.log(this.target.text);
        } else {
            console.log("Vous ne pouvez pas lire cet objet");
        }
        this.input_manager.request_input();
    }

Et enfin on vient créer notre première action concrète qui va nous permettre de lire le contenu d'un objet, uniquement si il contient bel et bien du texte !

Dans la prochaine partie de ce tutoriel nous viendront agrémenter notre moteur de jeu ainsi que notre monde afin de transformer cette simple liste d'objet en un vrai schéma de données plus complexe.

N'oubliez pas que vous pouvez toujours retrouver le code complet présenté dans ce tutoriel sur le Github du projet :

NicolasBrondin/line-adventure
Prototype de jeu d’aventure textuel tournant sur NodeJS - NicolasBrondin/line-adventure
En attendant j'espère que cet article vous aura plu, et je vous dis à bientôt sur le blog !

À propos de l'auteur

Hello, je suis Nicolas Brondin-Bernard, ingénieur web indépendant depuis 2015 passionné par le partage de d'expériences et de connaissances.

Aujourd'hui je suis aussi coach pour développeurs web juniors, tu peux me contacter sur nicolas@brondin.com, sur mon site ou devenir membre de ma newsletter pour ne jamais louper le meilleur article de la semaine et être tenu au courant de mes projets !


Photo by Annie Spratt on Unsplash