PyChemin

Un langage simple et naturel pour écrires des aventures textuelles interactives

Code source

La syntaxe

PyChemin utilise une syntaxe très brève, constituée de séquences de caractères que l'on ne peut trouver facilement dans du texte, permettant l'écriture de contenu sans encombres. Pas d'ambiguité possible.

Narrateur

Quand le narrateur parle écrivez simplement votre texte. Pas de syntaxe nécéssaire, car c'est le plus utilisé dans une histoire textuelle:

Il était une fois, en 2020, un langage de description d'histoire

Personnage

Quand un personnage parle, la syntaxe reste très légère:

[Personnage] Bonjour, monde!

Personnage fait référence à l'objet correspondant définit dans characters.py. Le nom affiché à l'utilisateur sera la propriété name de l'objet.

Questions au joueur

Vous serez sûrement amené, dans votre histoire, à interagir avec votre personnage.

Voici comment s'y prendre:
? Psst, choisissez un langage de programmation!
>> js, javascript, python, ruby
    [Ewen] Tu aimes le haut-niveau toi!
>> cpp, c++, c
    [Ewen] Hmm. Je vois. Moi les pointeurs, ça me fait peur!
>?.
    [Ewen] Ah. Je ne connais pas ton langage. Tan pis, ça à l'air de te plaire, et c'est l'essentiel!

La première ligne, commençant par un "?", est facultative. Elle permet d'aider le joueur à repondre à la question, mais, la plupart du temps, le narrateur guide les choix du joueur.

Les lignes commençant par ">>" indiquent les choix possibles, séparés par des virgules. Tout les réponses données par le joueur qui sont dans cette liste effectueront les actions dans ce bloc.

Ne vous inquiétez pas, PyChemin est assez intelligent pour comprendre que "javascirpt" correspond à la première ligne

La ligne ">?." Indique quoi faire si le joueur répond en dehors des clous. En écrivant ">?" au lieu de ">?.", on indique à PyChemin que la question doit ensuite être posée de nouveau. Avec un point à la fin, l'histoire continue normalement.

"Et si j'ai plusieurs choix et que le joueur doit passer par toutes les réponses?"

Pas de soucis. Utilisez ">*" au lieu de ">>":

[Mitsuha] Il doit bien être quelque part! Cherche dans ce cabanon
Vous vous dirigez vers un cabanon, recouvert par des lianes.
Après plusieurs minutes de combat, vous entrez dans le cabanon.
Il y a un pot de miel, une guitare et un trombone, tous posés sur un coffre au fond de la salle.
>* Miel
  Le miel semble bon. 
  Mais vu la date de péremption, dépassée depuis 32 ans, vous ne risquez pas une tartine.
>* Guitare
  Elle est totalement désaccordée
>* Trombonne
  Il requiert tant de souffle que vous n'arrivez pas à en faire sortir un seul son
Après avoir inspecté tout les objets de la pièce, vous remarquez que le soleil a changé.

Chapitres & Actes

Vous pouvez facilement découper votre histoire en actes et en chapitres:

=== Le Nouvel Ordre ===
[GénéralDuWSL] Suivez moi.

--- Le mystère des acronymes ---

Notez que le nombre de tirets ou de signes égal ("=") doit être au minimum de 2

La numérotation des chapitres & actes se fait automatiquement. Voici ce que verra l'utilisateur:

Découpez votre histoire

Quand votre magnifique histoire interactive aura grandi, il est important de rester organisé et de ne pas avoir 90 000 000 lignes dans un fichier.

C'est pour ça qu'il est important de créer plusieurs fichiers, et de charger les bons au bon moment.

Pour ce faire, créer d'autres fichiers .pychemin dans le même dossier, et utilisez des underscores "_" au lieu des espaces.

En suite, pour les appeler dans votre histoire, rien de plus simple:

Vos choix pourront peut-être changer l’histoire... A vous de choisir votre camp.
>> reine, lydia, von hardenberg, royaume
    -> côté reine 1
>> resistance, revolte, rebellion, résistant
    -> côté résistance 2
>?
    Veuillez choisir un camp !
1
Lancera "cote_reine.pychemin"

2
Lancera "cote_resistance.pychemin"

Changer des stats

Votre joueur ainsi que les personnages avec qui il interagit ont sûrement plusieurs propriétés pouvant changer en fonction des actions. Vous pouvez changer ces propriétés facilement, voici la syntaxe:

@Cible:propriété opération

Plusieurs opérations sont disponibles

Ajouter, soustraire ou multiplier

Utilisez les symboles usuels: +, - et *

@Melinda:relation +5
Régler à une valeur avec =

Règle à une valeur donnée, peut importe la valeur précédente.

C'est la seule opération qui peut accepter des interpolations.

Vous finissez par craquer et mangez le pot de miel entier.
@Player:food +100
Il est bon, certes, mais vous avez une sensation curieuse pendant un instant.
Vous en revoulez.
Vous en revoulez tellement qu'il y avait sûrement de la nicotine dedans.
Hélàs, le pot n'est plus qu'un verre totalement transparent.
@Player:hungry_for_honey =1

Quand la cible est le joueur, vous pouvez l'omettre:

@food -15

Revient à faire...

@Player:food -15

Quand votre stat sera changée, le joueur le vera dans l'interface.

Comme le nom de propriété lié à l'objet python n'est pas toujours très joli, il faut associer le nom de propriété à un nom lisible.

Pour ce faire, ouvrez constants.py et ajouter des entrées dans le dictionnaire STAT_NAMES

Les propriétés n'ayant pas de nom correspondant seront quand même modifiée, mais n'apparaîtront pas dans l'interace, ce qui peut être pratique.

Les conditions

Pour exécuter du code conditonnellement sans avoir à demander l'avis du joueur, vous pouvez utiliser des conditions:

=? #is_from == 'siberia' 1
  [Boris] Salut, je te sers une bière?
=? #is_from == 'grand_harz' 2
  Борис ne semble pas vouloir vous parler.
=! 3
  [Boris] Salut !
1
La condition à mettre après =? est du code python, avec #truc remplacé par self.player.truc.

2
Plusieurs =? peuvent être comme on echaînerait des if...elif...elif...

3
Code à exécuter si l'action précédente est fausse

Inclure du contenu dynamique

Comment faire dire à un personnage "Salut, (nom du joueur ici)" ?

PyChemin propose la syntaxe suivante:

[Melinda] Hello, #name !

Ici, #name sera remplacé par le nom du joueur.

Voici la liste des variables utilisables

name
Le nom du joueur
answer
La dernière réponse du joueur.

Comment ça marche?

characters.py et player.py

Certaines données sont décrites par des objets python. Tout les personnages sont des objets de la classe Character et le personnage est un objet de la classe Player.

Par exemple, voici comment est ajouté un personnage que l'on pourra référencer par "GénéralDuWSL" dans le fichier pychemin:

characters.py
GénéralDuWSL = Character(
  name="Général du WSL",
  # ...
)

Les deux classes héritent de la classe Being, qui implémente le changement de stat, car cette action est la même que la cible soit le joueur ou un personnage.

grammar.lark

La grammaire décrit la syntaxe d'un langage, par exemple, la ligne suivante

[Momodini] Vous avez eu 20/20 en IZN, #name !
est décrite comme une char_line — ou ligne de personnage — et représente un texte prononcé par le personnage "Momodini".

PyChemin utilise une libraire appelée lark pour décrire et parser sa grammaire.

La ligne de l'exemple précédent est décrite comme ceci:

grammar.lark
_char_text: _text
_char_name: "[" /[^\]]+/ "]"
char_line.2: _char_name " "* _char_text

On n'entrera pas dans les détails de cette syntaxe mais, basiquement, un fichier de grammaire est composé de plusieurs règles, chacune décrivant des motifs pouvant être rencontré dans le texte à parser.

Ici, la ligne _char_name: "[" /[^\]+/ "]"] déclare une règle, appelée char_name, qui décrit la composition d'un nom de personnage:

  1. Un caractère "["
  2. N'importe quel caractère excepté "]", une fois ou plus (en utilisant des expressions régulières1)
  3. Un caractère "]"

Une règle peut en contenir d'autres: la ligne char_line.2: _char_name " "* _char_text fait référence à deux autres règles.

Le fichier contient plusieurs de ces règles, et lark va les utiliser pour parser notre fichier .pychemin

parser.py

Le travail du parser, c'est de scanner notre fichier .pychemin, et de, à l'aide du fichier décrivant la grammaire, extraire les informations utiles à l'exécution.

Il y a ici deux concepts importants:

Les tokens

Ce sont des objets contenant une propriété value, qui contient la valeur qu'ils représentent, et une propriété type qui indique la nature de la valeur qu'ils représentent.

Les trees

Les trees sont composés d'un type, qui est égal au nom de la règle, et de plusieurs tokens ou autres trees.

Similairement aux tokens, les trees ont aussi un type.

Notre syntaxe est découpée de la manière suivante:

Dialog
  |—— Ligne 1
        |—— Indentations 2
        |—— Directive 3
1
Une ligne est analogue à une véritable ligne dans le fichier, mais fragmentée selon la grammaire

2
Chaque ligne peut avoir zéro, un ou plusieurs tokens d'indentation

3
Une directive représente l'instruction décrite dans le fichier .pychemin, tel qu'une ligne du narrateur.

Un petit travail de post traitement transforme cette structure d'arbre en une liste de couples (niveau_dindentation, directive).

Connaître ce niveau d'indentation nous sera utile pour traiter les blocs indentés.

Fichier .pychemin
[Ewen] Je ne sais pas quoi dire.
>> moi non plus
  [Ewen] Et Melinda ? Tu es d'accord avec elle ?
  >> oui
    @Melinda:relation +5
  >?.
    [Ewen] Bref, il faut sortir de cet exemple...

Par exemple, voici une représentation d'un AST pour la cinquième ligne de l'extrait ci-dessus

walker.py

Une fois que notre fichier a été transformé en une liste de directives (avec leur niveau d'indentation respectifs), il faut enfin exécuter ce fichier, et afficher quelque chose au joueur.

C'est le travail du walker, qui va itérer chaque ligne et appeler les méthodes correspondantes au type de directive. Chaque directive est associée à une méthode, appelée handle_<type de la directive>.

Cependant, ce n'est pas aussi simple pour les directives qui sont conditionnées et ne s'éxecuteront pas à chaque fois.

Gérer les conditions

Fichier .pychemin
[Artur] C'est bien #name ?
>> oui
    @Artur:relation +5
>> non
    Il soupire, désespéré
>?.
    Il soupire
    [Artur] Je prends ça pour un oui...

Ici, la ligne Il soupire, désespéré ne sera exécutée que si le joueur répond "oui": on ne peut pas juste itérer les lignes une par une et les exécuter: pour gérez des conditions, on doit d'abord collecter les conditions et leur associer des directives à exécuter. Pour cela, la classe Walker possède plusieurs propriétés.

On prendra par la suite l'exemple des directives constituants des questions à poser au joueur, mais une logique similaire est applicables aux directives =? et =!

collecting_input (booléen)
Permet de savoir si l'on est en train de collecter des directives, pour éviter de les exécuter directement.
current_input (instance de CurrentInput)
Contient des informations sur la question que l'on va poser. Ses propriétés sont modifiées au fur et à mesure que l'on collecte les informations nécéssaires avant de poser la question au joueur.
current_input.actions_map (dictionnaire)

C'est la propriété centrale de current_input. Elle contient une corresponsance entre une liste de choix et une liste de directives à exécuter si la réponse de l'utilisateur fait partie des choix correspondant.

Le souci, c'est que python n'accepte pas d'utiliser des listes comme clés de dictionnaires. Il faut donc transformer cette liste en une chaîne de caractères, avec le module json.

Pour se faire une idée de la logique, voici un exemple:

On va exécuter l'algorithme suivant pour chaque entrée:

Si en train de collecter des informations sur current_input

Si nous sommes en train de collecter des informations sur current_input, il y a deux cas possibles:

Si le niveau d'indentation est supérieur à celui de current_input

Dans ce cas là, la directive actuelle est indentée par rapport à la liste de choix précédente: c'est une directive à exécuter si la liste de choix la plus proche est celle choisie par l'utilisateur

  1. On ajoute la directive à current_input.actions_map[current_case]
Sinon

Dans ce cas là, on est face à deux situations:

Si la directive est une liste de choix

Dans ce cas, nous sommes passé à la description d'un autre cas.

  1. On ajoute un nouveau cas à current_input.actions_map
  2. On change la valeur de current_case pour l'actualiser à la nouvelle liste de choix (c'est à dire la directive actuelle)
Sinon

Dans ce cas, nous sommes sortis de la condition, et cette ligne décrit tout simplement une directive quelconque qui ne concerne plus la réponse du joueur.

  1. On appelle ui.ask pour poser la question au joueur
  2. On exécute les directives associées à la réponse du joueur
  3. On exécute normalement la directive actuelle (voir le dernier "Sinon")
Sinon
  1. On récupère la méthode à appeler en fonction du type de directive
  2. On appelle cette méthode

Cet algorithme marche également dans des situations de blocs imbriqués, avec plusieurs conditions de choix les unes dans les autres, grâce à de la récursion: la méthode appelée pour exécuter les directives collectées est la même que celle dans laquelle l'appel est fait. Par ce moyen, peut importe à quel niveau d'indentation nous sommes, on peut traiter des conditions.

ui.py

Ce module décrit exclusivement des éléments liés à ce qui est visible au joueur: comment devrais-je afficher le texte du narrateur, et cætera.

Écrire le texte avec un effet "machine à écrire"

Au lieu d'écrire du texte instantanéement, il est plus sympathique et captivant de lire un texte qui s'écrit au fur et à mesure.

La fonction typewritter répond précisémment à ce besoin, en fragmentant le texte en caractères et en ajoutant un délai entre l'écriture de chaque caractère vers le terminal. De plus, cette fonction ajoute des pauses à la fin des phrases.

Afficher des changements de statistiques

Quand une statistique a un nom humain associé, il faut que l'utilisateur voit que sa stat a changée.

Pour ce faire, on calcule la différence entre l'ancienne valeur et la nouvelle afin de décider de la couleur du texte.

On affiche ensuite l'opération et sa valeur, le nom de la stat et la nouvelle valeur:

Faire parler les personnages

Les paroles prononcées par des personnages sont indentiques à celles du narrateur pour la partie texte sauf que l'on préfixe leur texte avec le nom du personnage.

Chaque personnage est un objet instancié de la classe Character, et possède donc une couleur. Elle est utilisée comme couleur de fond pour le nom du personnage, et l'on décide de la couleur du texte en fonction: noir si l'arrière-plan est clair, et blanc dans le cas inverse, pour améliorer la lisibilité du nom.

Notes

1 Nous n'expliquerons pas les expressions régulières ici, mais vous pouvez vous renseigner en ligne