C’est quoi ça, les relations ink ? J’ai lu toute la doc et c’est pas dedans !

C’est vrai que les relations ne sont pas un concept qui est officiellement dans ink, mais c’est un sujet qui revient régulièrement sur le Discord d’inkle pour plusieurs raisons : ils l’utilisent dans leurs jeux, ils ont envie de l’ajouter à ink (en tant que fonctionnalité de base du langage) parce que c’est très pratique et ils ont publié sur leur Patreon une version utilisable dès aujourd’hui (depuis 2021 en fait). Ce n’est pas dans la doc, mais c’est pas un secret non plus. Quoiqu’il en soit, après cet article (partie 1), vous connaitrez cet outil et après le suivant (partie 2), vous saurez également comment ça marche techniquement.

Qu’est-ce qu’une relation ?

Pour faire simple, une relation, c’est une information sous la forme d’un « verbe » qui connecte deux éléments. Par exemple : Alice aime Bob. Les deux éléments sont Alice et Bob, le verbe est aime.

Dans notre cas particulier, il s’agit de relations binaires, c’est à dire que la relation concerne deux éléments (il existe d’autres types de relations, par exemple ternaires, comme dans : A est entre B et C ; mais ce n’est pas géré par le code d’inkle).

Notons qu’une relation n’est pas symétrique, ça veut dire que Alice aime Bob, c’est différent de Bob aime Alice. Si on veut indiquer la réciprocité, on écrira explicitement les deux relations.

Enfin, ce premier exemple concerne deux éléments du même type (on peut supposer qu’Alice et Bob sont des personnes), mais on peut tout à fait établir une relation entre deux éléments de type différents : Florian conduit Clio (personne, véhicule), Médor est triste (animal, état), John a planté les graines magiques (personne, objet), la clé est en fer (objet, matière).

L’intérêt pour votre jeu ?

C’est de pouvoir définir toutes ces relations, de pouvoir les faire évoluer pendant le jeu et de pouvoir interroger le système !

  • Qui aime Bob ? Alice aime qui ?
  • Par quel PNJ ont été plantées les graines magiques ?
  • Est-ce que Médor est triste et affamé ?
  • Le joueur possède-t-il un objet en bronze de forme carrée ?
  • Maintenant, Médor est rassasié
  • Si le contenant et le contenu ont la même matière, alors la porte est déverrouillée

Pour les cas les plus simples, pas besoin de relations, quelques variables peuvent vous suffire, ou même juste le compteur de visite de nœuds. Pour le reste, ce système de relations est très pratique.

Mettre des éléments de liste en relation

Le système proposé par inkle fonctionne avec des listes. Une liste des relations possibles et des listes de choses à mettre en relation.

LIST Relations = FaitEn // Une seule relation pour cet exemple

LIST Métaux = Fer, Or, Cuivre
LIST Objets = Clé, Cadenas, Pièce_D_Or, Lance

Ensuite on définit les liens entre ces éléments :

~ relate(Clé, FaitEn, Cuivre)
~ relate((Cadenas, Lance), FaitEn, Fer) // On peut mettre une liste pour éviter les répétitions
~ relate(Pièce_D_Or, FaitEn, Or)

relate est une fonction fournie par le système des relations. Il fournit également de quoi poser deux types de questions.

Soit on connait le triplet (ItemDeGauche, Relation, ItemDeDroite) et on veut vérifier si la relation existe : dans ce cas on utilise isRelated(ItemDeGauche, ItemDeDroite, Relation) qui renvoie un booléen true ou false.

Soit on ne connaît qu’un item (gauche ou droite) et la relation : alors on utilise whatIs(ItemDeGauche, Relation) / whatIs(Relation, ItemDeDroite) qui renvoient la liste des éléments qui correspondent.

~ isRelated(Clé, Cuivre, FaitEn) // → true (l'ordre n'est pas très intuitif)
~ isRelated(Clé, Or, FaitEn) // → false

~ whatIs(Clé, FaitEn) // → Cuivre (ça marche mieux en anglais : what is key made of)
~ whatIs(FaitEn, Fer) // → (Cadenas, Lance)
~ whatIs(FaitEn, (Fer, Or)) // → (Cadenas, Lance, Pièce_D_Or) (on peut aussi utiliser une liste, qui est interprétée comme un "OU" = qu'est-ce qui est fait de fer ou d'or ?)

Et enfin, on peut défaire une relation :

~ unrelate(Lance, FaitEn, Fer)

Si le nom isRelated est plutôt clair, le nom de l’autre question proposée par le système, whatIs, n’est pas forcément très parlant. Ça marche bien en anglais pour l’exemple d’inkle : What is (the) key made of? What is made of iron? Mais pour certaines catégories, on préférerait avoir what, who, whoIs, where… Et tant qu’à faire, on pourrait les vouloir en français. On verra tout ça dans le prochain article, une fois qu’on aura compris le code, on pourra le modifier.

Mise en pratique

Si vous êtes convaincu ou si vous voulez juste tester le système, passons à la pratique.

Cet exemple assez complet est un mini-jeu. On y incarne un archéologue/explorateur qui cherche un trésor qui lui sera donné s’il résout l’énigme des bols : trois bols qu’il faut remplir avec les trois bons objets et un transmuteur pour changer la matière d’un objet afin de la faire correspondre à celle du bol.

Avant de regarder le code, vous pouvez y jouer sur itch ou juste ici :

Je ne vais pas tout montrer dans cet article car une bonne partie du code n’a rien à voir avec les relations. Vous pouvez néanmoins retrouver ici l’intégralité du code.
Vous y trouverez cinq fichiers :

  • histoire.ink : c’est le fichier principal, c’est lui qui raconte l’histoire et contient la mécanique du jeu, je m’attarderai sur les passages qui concernent les relations.
  • mes-relations.ink : c’est ici qu’on définit les relations possibles, on va bien sûr en parler.
  • relations-system.ink : le code fourni par inkle est un peu brut, c’est une démo. J’ai fait le tri pour vous et j’ai préparé ce fichier à inclure dans votre projet. Si vous voulez comprendre ce que fait ce code, rendez-vous au prochain article, le but de celui ci est juste de s’en servir.
  • texte.ink : comme le texte du jeu mentionne des objets, des formes et des matières qui peuvent changer, il faut un système pour que les articles corrects soient utilisés. Si je veux dire « l'{objet} en {matière} » il faut que ça affiche « la pièce en cuivre » ou « le dé en fer » et parfois, je veux dire « un {objet} » donc il faut afficher « une pièce » ou « un dé ».
    Ce fichier contient un système rudimentaire pour gérer ça mais ça n’a rien à voir avec les relations donc je n’en parlerai pas plus.
  • utils.ink : quelques fonctions utilitaires, notamment pour manipuler les listes, je n’en parlerai pas non plus.

Commençons par mes-relations.ink. Pour que tout fonctionne, il faut définir quelques éléments :

  1. la liste spéciale Relations qui contient les différentes relations que vous voulez avoir
  2. les listes de choses que vous voulez mettre en relation
  3. une fonction spéciale relationDatabase qui définit quelle liste est associée à quelle autre liste et via quelle relation (c’est plus simple à comprendre en lisant l’exemple)

Je dis « spéciales » parce que le code dans relations-system.ink s’attend à ce que Relations et relationDatabase existent et vous n’avez pas le choix du nom (sauf à modifier relations-system.ink).

Alors allons-y et créons un fichier mes-relations.ink.

mes-relations.ink

Voici les relations qu’on peut avoir dans ce jeu :

LIST Relations = Contient, EstEn, EstDeForme, Réclame, Possède, Connait

Et les choses auxquelles elles s’appliquent :

LIST Personnes = Hervé, Sandrine
LIST Objets = Transmuteur, BolGauche, BolCentral, BolDroit, Pièce, Dé, Bille
LIST Matières = Cuivre, Terre, Fer, Verre, Argile, Bois
LIST Formes = Cercle, Carré, Triangle

Il faut définir la fonction spéciale qui dit à ink quelles listes sont en relation. Ink l’appellera pour savoir quels sont les éléments possibles à gauche ou à droite d’une relation. Pour ça, il nous passe une relation et un booléen qui vaut true s’il veut savoir ce qu’il y a du côté gauche de la relation et false pour savoir ce qu’il y a du côté droit.

J’ai ajouté une fonction intermédiaire gauche_droite uniquement pour des raisons de formatage, je trouve pratique de voir toutes les relations d’un coup chacune sur sa ligne.

=== function relationDatabase(relation, gauche)
{ relation:
// Ex : La relation EstEn lie des Objets (à gauche) à des Matières (à droite)
- EstEn:      ~ return gauche_droite(gauche, Objets,    Matières)
- Contient:   ~ return gauche_droite(gauche, Objets,    Objets)
- Réclame:    ~ return gauche_droite(gauche, Objets,    Formes)
- EstDeForme: ~ return gauche_droite(gauche, Objets,    Formes)
- Possède:    ~ return gauche_droite(gauche, Personnes, Objets)
- Connait:    ~ return gauche_droite(gauche, Personnes, Objets)
}

=== function gauche_droite(gauche, listeGauche, listeDroite)
{ gauche:
~ return listeGauche
- else:
~ return listeDroite
}

Et voilà, c’est tout ce qu’il y a à faire pour mettre en place les relations. Maintenant, on peut écrire une histoire qui utilise ces relations !

histoire.ink

On inclut tous les fichiers mentionnés plus tôt.

INCLUDE relations-system.ink
INCLUDE mes-relations.ink
INCLUDE texte.ink
INCLUDE utils.ink

Définissons quelques relations initiales :

~ relate(Pièce, EstEn,      Cuivre)
~ relate(Pièce, EstDeForme, Triangle) // Et ouais
~ relate(Dé,    EstEn,      Bois)
~ relate(Dé,    EstDeForme, Carré)
~ relate(Bille, EstEn,      Verre)
~ relate(Bille, EstDeForme, Cercle)

// Oh, on peut gérer un inventaire avec les relations !
~ relate(Hervé, Possède, Dé)
~ relate(Hervé, Possède, Bille)
// La pièce est dans une salle, Hervé ne la possède pas

// On veut tirer des matières au hasard sans piocher 2 fois la même.
// On fait donc une copie de la liste dont on va à chaque fois enlever un élément aléatoirement (pop_random, une fonction disponible dans l'éditeur Inky).
~ temp m = LIST_ALL(Matières)
~ relate(BolGauche,  EstEn, pop_random(m))
~ relate(BolCentral, EstEn, pop_random(m))
~ relate(BolDroit,   EstEn, pop_random(m))

~ temp f = LIST_ALL(Formes)
~ relate(BolGauche,  Réclame, pop_random(f))
~ relate(BolCentral, Réclame, pop_random(f))
~ relate(BolDroit,   Réclame, pop_random(f))

Si c’était un vrai jeu et pas juste un exemple, on pourrait vouloir s’assurer qu’on n’est pas tombé aléatoirement sur un cas trop simple à résoudre (tous les objets déjà de la bonne matière).

Maintenant que l’initialisation est faite, on peut rentrer dans l’histoire. On va avoir une === intro, qui va nous mener à la === salle_des_bols. Une fois qu’on aura regardé les trois bols, on débloquera l’accès à la === salle_du_transmuteur ainsi qu’à l’=== inventaire.


-> intro

=== intro
[ Pavé de texte que vous pouvez retrouver dans la version complète. ]

+ [Continuer] -> salle_des_bols

=== salle_des_bols
{ TURNS_SINCE(-> salle_du_transmuteur) == 1:
    Vous reprenez le tunnel jusqu'aux bols.
- else:
    Trois bols sont alignés sur un piédestal en pierre.
}
// Ne montrer que les bols tant qu'on ne les a pas tous examinés
// On pourrait gérer ça avec un simple compteur de visite (vu_bol_xyz) sur chaque choix de bol, mais c'est un article sur les relations ;-)

~ temp tous_bols_vus = LIST_COUNT(whatIs(Hervé, Connait)) == 3

+ [Bol gauche  {info_bol(BolGauche)}]  -> menu_bol(BolGauche) ->
+ [Bol central {info_bol(BolCentral)}] -> menu_bol(BolCentral) ->
+ [Bol droit   {info_bol(BolDroit)}]   -> menu_bol(BolDroit) ->
+ {tous_bols_vus}
    [{salle_du_transmuteur:Aller au transmuteur|Explorer la grotte}]
        -> salle_du_transmuteur
+ {tous_bols_vus}
    [Inventaire]
        -> inventaire ->
-
->  redirect_0(-> salle_des_bols)

Ici, le seul morceau qui concerne les relations, c’est la ligne :
~ temp tous_bols_vus = LIST_COUNT(whatIs(Hervé, Connait)) == 3
Avec whatIs(Hervé, Connait), on récupère une liste des objets connus par Hervé. Puis on compte le nombre d’éléments et si c’est 3, c’est qu’Hervé a examiné les 3 bols. Si c’est le cas, on débloque les choix « Explorer la grotte » et « Inventaire ».

Le code utilise également info_bol et menu_bol. La première fonction permet de donner des détails sur un bol s’il est connu. On vérifie cela avec isRelated(Hervé, bol, Connait). Si c’est le cas, on récupère contenu et matière avec whatIs(bol, Contient) et whatIs(bol, EstEn).

=== function info_bol(bol)
// Afficher les infos uniquement si le bol est connu
~ temp connu = isRelated(Hervé, bol, Connait)
{ not connu:
    ~ return
}
~ temp contenu = whatIs(bol, Contient)
~ temp matière = whatIs(bol, EstEn)
~ temp texteContenu = "{contenu:contient {t("un", contenu)}|vide}"
~ return "({texteMatiere(matière)}, {texteContenu})"

Le nœud menu_bol emploie du code similaire pour récupérer contenu, matière et symbole.

C’est là qu’on déclare qu’Hervé connait le bol, et s’il prend quelque chose dans le bol, on modifie les relations avec relate et unrelate. Ça permet de « déplacer » un objet du bol vers l’inventaire.

=== menu_bol(bol)
~ relate(Hervé, Connait, bol)
~ temp contenu = whatIs(bol, Contient)
~ temp matière = whatIs(bol, EstEn)
~ temp symbole = whatIs(bol, Réclame)

Ce bol est {texteMatiere(matière)}.
<> {contenu:Il contient {t("un", contenu)}|Il est vide}.
<> {contenu:Sous {texteObjet("le", contenu)}|Au fond}, vous distinguez un symbole : {texteSymbole(symbole)}.

// S'il y a quelque chose dans le bol, on peut le reprendre, sinon on peut y déposer un objet
+ {contenu} [Reprendre {contenu}]
    ~ relate(Hervé, Possède, contenu)
    ~ unrelate(bol, Contient, contenu)
+ {not contenu} [Déposer] -> menu_deposer(bol) ->
    // Après un dépôt, on vérifie les bols
    { verifier_bols():
    - Victoire: -> victoire
    - Défaite: -> defaite
    }
+ [Retour]
-
->->

Je passe sur le nœud menu_deposer qui génère un choix pour chacun des objets de l’inventaire puis utilise également relate/unrelate.

La salle du transmuteur est similaire à la salle des bols, à part qu’on met les objets dans le transmuteur au lieu des bols. Et bien sûr, le transmuteur transmute…

Au début, la pièce (l’objet) est dans cette salle mais on ne s’est pas embêté à faire un système générique pour prendre et déposer des objets dans les lieux. On pourrait matérialiser ça avec une relation entre des Lieux et des Objets, mais puisque c’est le seul concerné, on peut faire autrement. La pièce peut être à 4 endroits : dans l’inventaire d’Hervé, dans un des bols, dans le transmuteur ou dans la salle. Si Hervé ne possède pas la pièce et qu’aucun objet (bols ou transmuteur) ne contient la pièce, alors il ne reste plus qu’une possibilité : la pièce est dans la salle. On vérifie cela en comptant le nombre d’items en relation avec la pièce via Contient et Possède.
~ temp piece_ici = LIST_COUNT(whatIs(Contient, Pièce) + whatIs(Possède, Pièce)) == 0

=== salle_du_transmuteur

{ TURNS_SINCE(-> salle_des_bols) == 1:
    {La grotte n'est constituée que de deux chambres connectées par un étroit tunnel. Vous vous retrouvez donc la salle du transmuteur que vous aviez découvert précédemment avec Sandrine.|Vous vous faufilez jusqu'au transmuteur}
}

~ temp piece_ici = LIST_COUNT(whatIs(Contient, Pièce) + whatIs(Possède, Pièce)) == 0
{piece_ici: Un objet triangulaire traine dans la poussière.}

~ temp contenu_transmuteur = whatIs(Transmuteur, Contient)

+ {piece_ici} [Ramasser l'objet triangulaire]
    Vous ramassez le triangle, qui semble {texteMatiere(whatIs(Pièce, EstEn))}. Vous reconnaissez le symbole qui est gravé sur une des faces : c'est une pièce de monnaie.
    ~ relate(Hervé, Possède, Pièce)
+ {contenu_transmuteur} [Récupérer {t("le", contenu_transmuteur)}]
    ~ relate(Hervé, Possède, contenu_transmuteur)
    ~ unrelate(Transmuteur, Contient, contenu_transmuteur)
+ {not contenu_transmuteur} [Déposer un objet dans le transmuteur]
    -> menu_deposer(Transmuteur) ->
    ~ contenu_transmuteur = whatIs(Transmuteur, Contient)
    { contenu_transmuteur:
        -> transmuter(contenu_transmuteur) ->
    }
+ [Retourner dans la chambre des bols]
    -> salle_des_bols
+ [Inventaire]
    -> inventaire ->
-
-> redirect_0(-> salle_du_transmuteur)

Pour terminer, on peut regarder la fonction verifier_bols() qui décide si on a gagné/perdu ou s’il faut continuer. Notamment, on vérifie avec plusieurs appels à isRelated() que le contenu du bol a la bonne forme et la bonne matière.

=== function verifier_bols()
LIST StatutBols = Incomplet, Victoire, Défaite
~ temp contenuGauche = whatIs(BolGauche, Contient)
~ temp contenuCentral = whatIs(BolCentral, Contient)
~ temp contenuDroit = whatIs(BolDroit, Contient)

// Si < 3 contenus, le puzzle est incomplet
{ LIST_COUNT(whatIs((BolGauche, BolCentral, BolDroit), Contient)) < 3:
    ~ return Incomplet
}
{ verifier_bol(BolGauche) and verifier_bol(BolCentral) and verifier_bol(BolDroit):
    ~ return Victoire
- else:
    ~ return Défaite
}

=== function verifier_bol(bol)
// On valide l'objet et sa matière
~ temp contenu = whatIs(bol, Contient)
~ temp matière_contenu = whatIs(contenu, EstEn)
~ temp forme_contenu = whatIs(contenu, EstDeForme)
~ temp bol_ok = isRelated(bol, forme_contenu, Réclame) and isRelated(bol, matière_contenu, EstEn😱
~ return bol_ok

Conclusion

Si vous voulez lire tout le code, il est disponible sur GitHub, mais avec ce qu’on a vu, vous savez déjà utiliser relationDatabase, relate, unrelate, whatIs et isRelated, et c’est tout ce dont vous avez besoin de savoir pour mettre en place des relations dans vos jeux.

Si vous êtes curieux de savoir comment ce système fonctionne (spoiler : tout est mis dans une grosse chaine de caractères parce qu’ink n’a pas de structures de données 😱) ou si vous voulez construire un système un peu plus sophistiqué par dessus, je vous donne rendez-vous pour la partie 2.