Dans l’article précédent sur Calico, je vous ai parlé des fonctionnalités de base mais l’essentiel de l’article concernait les fonctionnalités ajoutées par les patches. Je vous disais que nous verrions plus tard comment en créer nous-mêmes. Et bien ce plus tard, c’est maintenant !

C’est quoi au juste un patch ? C’est un fichier JavaScript qui ajoute à Calico des capacités qui ne sont pas présentes de base. Calico nous fournit des briques fonctionnelles que nous allons utiliser pour construire nos nouvelles fonctionnalités. Mais sachez que nous ne sommes pas limités à ce que nous fournit Calico : nous sommes dans un environnement Web. Nous avons donc accès au CSS pour changer le style de la page, au DOM pour en modifier la structure et même à d’autres librairies JavaScript si nécessaire. Et puisqu’on fait tourner une histoire ink, on a aussi accès à inkjs bien sûr.

Ça fait beaucoup de choses et on ne va pas tout voir dans cet article. En particulier, ce n’est pas un tutoriel JavaScript/DOM/CSS. On va en faire beaucoup dans les exemples car c’est indispensable mais mes explications porteront surtout sur Calico.

Dans le reste de cet article, pour explorer les outils que nous offre Calico, plutôt que d’en faire une longue liste, je vais partir d’envies de fonctionnalités, je vous montrerai comment les coder et je ferai les apartés techniques qui s’imposent. Je mets également à votre disposition le code complet de tous les exemples sur ce dépôt. Vous pouvez également le récupérer directement au format zip.

J’ai regroupé l’ensemble des exemples de cet article (ainsi que des bonus) dans une démo que vous pouvez consultez sur itch.io ou directement ici :

Structure d’un patch

Commençons par la structure basique d’un patch :

Patches.add(function () {
  // Cette fonction est appelée quand la story ink est chargée, juste avant qu'elle ne démarre
  // C'est ici qu'on peut manipuler les briques de base de Calico.
});

La plupart du temps, vous aurez besoin d’accéder à la story pour faire quelque chose. Comme votre patch est dans un autre fichier, il n’a pas accès à la variable story créée dans le project.js. Bien sûr, Calico nous la fournit, mais d’une manière un peu surprenante : la story, c’est le this de la fonction définie dans le Patches.add(). Le concept de this en JavaScript est complexe, des milliers d’articles lui sont consacrés et c’est quelque chose qui est souvent mal compris. Je vous propose donc d’évacuer ça tout de suite et de ne plus y penser.

Patches.add(function () {
  const story = this;
  // Voilà, à partir de maintenant on utilise story
});

Pour être tout à fait exact, il y a certaines briques de Calico qu’on peut utiliser en dehors de Patches.add() parce qu’elles vous fournissent également une référence vers story mais il est plus simple de tout mettre dedans.

Si vous voulez partager votre patch, il faudra ajouter quelques infos comme le nom de votre patch, le votre, choisir une licence et probablement exposer quelques options. Je traite de ce sujet à la toute fin de l’article !

Je voudrais… changer la couleur de fond

Je pense qu’ajouter un tag est une des choses les plus faciles et c’est pourtant très puissant.
C’est facile, parce qu’il suffit de donner le nom de votre tag et la fonction qui est appelée quand ink rencontre ce tag. C’est puissant parce qu’une fois dans cette fonction, vous pouvez faire beaucoup de choses.

Tags.add("nom du tag", function (story, texteAprèsLeTag) {
  //…
});

Qu’est-ce que se passe ici ?

  • on appelle Tags.add() avec deux paramètres
    • le premier, le nom du tag, sans #
    • le deuxième, une fonction que Calico appellera quand il tombera sur le tag dans ink
  • votre fonction sera appelée avec deux paramètres
    • l’objet story. C’est celui qui est créé avec new Story("story.ink"). Je vous ai dit qu’il y a certaines briques de Calico qui vous fournissent une référence à story, Tags.add() en est une.
    • le texte qui est après le tag. C’est un peu le paramètre du tag. Par exemple, pour # image: toto.png, vous recevrez " toto.png" (avec l’espace).

En particulier, pour changer la couleur de fond, on veut créer un tag # background-color et on va passer la couleur en paramètre de tag. En JavaScript, changer la couleur de toute la page est assez simple : on applique la couleur de fond au style du <body>. Le body est directement accessible avec document.body. Ça donne donc :

Patches.add(function (){
  Tags.add("background-color", function (story, color) {
    document.body.style.backgroundColor = color;
  });
});

Voilà, avec ces 5 lignes, on a un nouveau tag disponible pour ink, # background-color, qui s’utilise avec n’importe quelle couleur CSS et qui va changer la couleur de fond de toute la page.

+ [Se cacher dans la forêt] #background-color: darkgreen
+ [Partir dans le désert] #background-color: \#fae27b 
// Note : on échappe le # de la couleur hexadécimale pour qu'il ne soit pas considéré comme un deuxième tag

Vu que le texte de Calico est blanc par défaut, ça ne sera pas forcément lisible si on choisit un fond clair, donc vous aurez peut-être envie de créer un deuxième tag :

Patches.add(function (){
  Tags.add("text-color", function (story, color) {
    document.body.style.color = color;
  });
});

Ou directement un tag qui gère deux couleurs :

Patches.add(function (){
  Tags.add("body-colors", function (story, colors) {
    const [text, bg] = colors.split("/");
    document.body.style.color = text;
    document.body.style.backgroundColor = bg;
  });
});

Il s’utiliserait comme ça :

+ [Partir dans le désert] # body-colors: yellow/black

Notre fonction reçoit le texte brut " yellow/black" (avec l’espace), c’est à nous de faire les manipulations nécessaires pour en extraire les deux couleurs.

Je voudrais… des checkpoints de sauvegarde

La sauvegarde automatique fournie par Calico via son patch autosave est intéressante mais je vous propose d’en faire une version un peu différente. Au lieu de sauvegarder systématiquement après chaque choix, nous n’allons sauvegarder qu’à des endroits stratégiques de l’histoire.
Ces endroits seront définis par des tags # checkpoint dans le code ink. En ce qui concerne la sauvegarde, nous allons faire comme autosave : utiliser memory qui est fourni et qui fait tout le travail.
Le code de base est très simple :

import memorycard from "./patches/memorycard.js";

Patches.add(function () {
  const story = this;
  Tags.add("checkpoint", function () {
    memorycard.save(story);
  });
});

Avec ça, nous avons une sauvegarde automatique à chaque fois que l’on atteint un # checkpoint.
Il nous faut maintenant le code pour retourner automatiquement à ce checkpoint quand on démarre le jeu. Pour ça, il faut que j’introduise un nouveau concept Calico : les évènements. Pour ceux qui connaissent les évènements en programmation, c’est exactement la même chose, pour les autres, je vais vous donner les bases nécessaires.

Parenthèse évènementielle

La programmation évènementielle, c’est une façon de programmer qui s’oppose à la programmation séquentielle.
Programmer en séquence, c’est assez intuitif : fais ça ; puis fais ceci ; puis fais cela ; puis c’est fini.
Programmer avec des évènements, c’est plutôt : quand il se passe ça, fais ça ; quand il se passe cette autre chose, fais ceci ; avant de finir, fais cela. C’est plus clair avec de vrais exemples d’évènements : quand on clique, change de page ; quand la musique s’arrête, démarre la suivante ; quand Calico démarre le jeu, charge le dernier checkpoint.

Dans la pratique, Calico réutilise le système d’évènements existant des pages web, mais avec ses propres noms d’évènements. Au lieu de réagir à “click” ou “mouseover”, on réagira plutôt à “passage start”, “render interrupted” ou encore “story ready” mais le code sera le même.

Votre story ink est affichée par Calico dans un élément conteneur (le <div id="container"> dans le index.html) que Calico appelle en interne “outerdiv”. Ce détail est important parce que c’est cet élément que Calico utilise pour déclencher 80% de ses évènements (quasiment tous ceux qui vous intéressent sont dans ce cas). Concrètement, ça veut dire que le code pour réagir à un évènement sera presque toujours de la forme :

story.outerdiv.addEventListener("nom de l'évènement", function (event) {
  // code qui réagit
});

L’autre variante très utile :

story.outerdiv.addEventListener("nom de l'évènement", function (event) {
  // code qui réagit
}, { once: true }); // on ajoute l'option "once"

Dans ce cas, on demande à ce que notre code ne soit appelé qu’une fois (once) au lieu de l’être à chaque fois que l’évènement se produit.

La plupart des évènements fournissent des arguments utiles pour réagir, ils sont rangés dans event.detail. Par exemple, quand un choix est ajouté à la page, l’évènement passage choice element est déclenché avec pour arguments l’élément DOM et le l’objet ink qui représente le choix.

story.outerdiv.addEventListener("passage choice element", function (ev) {
  const { element, choice } = ev.detail;
  // ...
});

Vous trouverez la liste complète des évènements déclenché par Calico dans la documentation officielle.

Retour à nos checkpoints

Avec ces bases, vous devriez comprendre ce code sans plus d’explications :

story.outerdiv.addEventListener("story ready", function () {
  memorycard.load(story);
});

Il y a un dernier évènement qui nous intéresse : le “restart”. Quand on redémarre une partie, on ne veut pas conserver le dernier checkpoint. On peut donc soit effacer la sauvegarde, soit la remplacer par une nouvelle sauvegarde en début de partie. memory ne propose malheureusement pas de fonction pour effacer une sauvegarde donc je choisis l’autre option (si vous en avez besoin, sachez c’est possible en utilisant directement storage) :

story.outerdiv.addEventListener("story restarting", function () {
  memorycard.save(story);
});

Et voici le code complet :

import memorycard from "./patches/memorycard.js";

Patches.add(function () {
  const story = this;
  Tags.add("checkpoint", function () {
    memorycard.save(story);
  });

  this.outerdiv.addEventListener("story restarting", function () {
    memorycard.save(story);
  });

  this.outerdiv.addEventListener("story ready", function () {
    memorycard.load(story);
  });
});

Je voudrais… retrouver le menu restart/save/load du template Inky

Une chose bien utile qu’on perd quand on passe du template d’Inky à Calico : le menu “restart | save | load | theme”. Nous allons en refaire une bonne partie ensemble. J’ai exclu le lien “theme” parce que ça fait pas mal de code pour une fonctionnalité qui n’a rien à voir avec Calico.

On commence par la base, ajouter les liens dans le HTML :

  <body>
    <div id="toolbar">
      <a id="restart">recommencer</a>
      <a id="save">sauvegarder</a>
      <a id="load">charger</a>
    </div>
    <div id="container">
      <div id="story"></div>
    </div>
  </body>

Je vous épargne le CSS, ce n’est pas particulièrement intéressant pour cet article, mais vous pouvez le retrouver dans le code complet.

Et maintenant notre patch !
On commence par récupérer des références vers les liens :

Patches.add(function () {
  const story = this;
  const restartEl = document.getElementById("restart");
  const saveEl = document.getElementById("save");
  const loadEl = document.getElementById("load");
  ...

Puis pour chacun, au clic, on utilise les fonctions de Calico : story.restart() pour rédémarrer et les fonctions save/load du patch memorycard.

Patches.add(function () {
  const story = this;
  const restartEl = document.getElementById("restart");
  const saveEl = document.getElementById("save");
  const loadEl = document.getElementById("load");

  restartEl.addEventListener("click", (ev) => {
    // restart() ne fonctionne pas si la story est en attente, on force donc son état à "idle".
    story.state = Story.states.idle;
    story.restart();
  });

  saveEl.addEventListener("click", function (ev) {
    // On sauvegarde avec le nom "user-save".
    memorycard.save(story, "user-save");
    // Et on active le lien "charger".
    loadEl.removeAttribute("disabled");
  });

  // Si au démarrage on n'a pas de sauvegarde en stock, on désactive le lien "charger".
  if (!hasSave(story)) {
    loadEl.setAttribute("disabled", "disabled");
  }

  // Pour charger, on vide l'écran et on charge la sauvegarde.
  loadEl.addEventListener("click", function (ev) {
    if (loadEl.getAttribute("disabled")) return;
    story.clear();
    memorycard.load(story, "user-save");
  });
});

Il manque juste la fonction hasSave() qu’il nous faut écrire. memorycard ne nous fournit pas cette information donc on va descendre un cran plus bas et utiliser directement le patch storage en lui passant les mêmes arguments que Calico lui aurait passés.

function hasSave(story) {
  const save = storage.get("user-save", story.options.memorycard_format, story);
  return Boolean(save); // true si on a une sauvegarde, false sinon.
}

Je voudrais… un autre effet de texte que le fondu

Calico est livré avec un seul effet de texte : le fondu.
Mais Calico est aussi livré avec un système pour en créer d’autres !

Pour ajouter un nouvel effet, la structure de base est celle ci :

TextAnimation.add("mon_animation", {
  added: function (el) {},
  show: function (el) {},
  rendered: function (el) {},
  hide: function (el) {},
});

Quand Calico ajoute un élément à la page, celui passe dans une moulinette dans laquelle notre animation va pouvoir intervenir :

  • Calico ajoute l’élément dans la page
  • puis appelle Element.added(el)
    • qui masque l’élément immédiatement avec une opacité à 0
    • puis appelle Element.show(el)
      • qui s’occupe de respecter les délais d’apparition
      • puis met l’opacité à 1 (uniquement s’il n’y a pas d’effet)
      • et appelle Element.rendered(el)

Notre effet personnalisé va pouvoir intervenir à chaque fois que Calico appelle Element.quelqueChose(). S’il y a un effet, il va appeler notre code à la place et c’est à nous d’appeler Element.quelqueChose() quand c’est nécessaire.

Pour clarifier tout ça, regardons le code (simplifié et commenté) de l’effet de fondu :

TextAnimation.add("fade", {
  added: function (el) {
    // On cache l'élément (c'est redondant car Calico l'a déjà fait)
    el.style.opacity = 0;
    // Puis on rend la main à la moulinette
    Element.show(el);
  },

  show: function (el) {
    // On lance la transition sur l'opacité
    transition(el, "opacity", 1, "500ms ease").then(() => {
      // Quand c'est fini, on rend la main
      Element.rendered(el);
    });
  },

  rendered: function (el) {
    // Rien de particulier à faire ici
  },

  hide: function (el) {
    // On fait pareil quand on enlève un élément
    transition(el, "opacity", 0, "500ms").then(() => {
      Element.remove(el);
    });
  },
});

transition() est une fonction fournie par Calico pour gérer les cas simples où l’on veut animer une seule propriété CSS. Si vous voulez faire plus compliqué, il faudra gérer ça vous même (probablement à base de “transitionend” ou “animationend”) ou déléguer à une autre librairie.

Une info utile si vous vous lancez dans l’écriture d’un effet : quand ink produit plusieurs paragraphes d’affilée, Calico les crée et appelle added() sur tous les paragraphes en parallèle. Ensuite show() est déclenché également sur tous les paragraphes (avec un léger décalage parce qu’il respecte les délais d’apparition définis dans les options). Si vous voulez animez les paragraphes en séquence plutôt qu’en parallèle, il faudra ruser. Vous pouvez manipuler le delay de chaque élément si vous connaissez le temps d’animation ou, plus complexe mais parfois nécessaire, vous pouvez attendre vous même la fin du rendu de l’élément précédent avant d’appeler Element.show(). Voici une fonction qui permet de faire ça :

function whenPreviousIsRendered(el, callback) {
  const previousEl = el.previousSibling;
  if (
    previousEl &&
    (previousEl.state === Element.states.added ||
      previousEl.state === Element.states.rendering)
  ) {
    // If the element is not yet rendered, subscribe to onRendered
    Element.addCallback(previousEl, "onRendered", callback);
  } else {
    // Otherwise, call immediately
    callback();
  }
}

Voyons tout de suite son utilisation avec la création d’un effet “machine à écrire”. Pour ça, j’utilise une librairie externe. Comme l’utilisation de celle ci n’est pas le sujet de l’article, je ne vous donne ici que le code concernant Calico, vous pouvez aller voir le code complet pour les détails.

TextAnimation.add("typewriter", {
  added(el) {
    createTypewriter(el);
    // On veut une animation en séquence donc on attend la fin de l'animation précédente.
    whenPreviousIsRendered(el, () => {
      Element.show(el);
    });
  },
  show(el) {
    el.style.opacity = "";
    executeTypewriter(el, () => {
      Element.rendered(el);
    });
  },
  rendered(el) {},
  hide(el) {
    Element.remove(el);
  },
});

Maintenant qu’on a créé un effet, voyons comment l’utiliser !
Si vous le voulez systématiquement, c’est on ne peut plus simple :

options.textanimation = "typewriter";

Si vous voulez appliquer un effet partiellement, par exemple seulement sur les paragraphes qui ont un certain tag, il faut utiliser dans le code de votre tag (vous savez faire maintenant) :

TextAnimation.apply(story, el, "typewriter");

Je voudrais… quitter une conversation en cours de route

Alors dit comme ça, c’est hyper spécifique et… c’est vrai. Mais c’est pas grave parce que ça me permet de vous présenter trois nouveaux concepts dans un exemple plutôt court et simple.

Pour cet exemple, imaginez un personnage qui parle au joueur. Beaucoup. Trop. Vous avez envie de proposer à votre joueur de quitter la conversation à tout moment. Vous pourriez ajouter un choix “Quitter” à chaque fois qu’il y a des choix dans la conversation mais ce serait un peu lourd, ça rendrait le code source moins clair et surtout je ne pourrais pas vous parler de ces trois nouvelles choses ?. Ce qu’on va faire à la place, c’est que le code ink va pouvoir dire “à partir de maintenant et jusqu’à nouvel ordre, on peut quitter et aller là bas” et que le joueur pourra quitter simplement en appuyant sur la touche Échap de son clavier.

Nous allons donc voir :

  • comment appeler une fonction JavaScript depuis ink
  • comment détecter l’appui sur une touche avec Calico
  • comment contourner le cheminement établi de votre histoire et aller directement ou on veut

Voici le code ink qu’il faut faire fonctionner :

EXTERNAL set_exit_knot(knot)

Vous pouvez quitter une conversation en cours avec la touche Échap/Esc

* [Conversation] Une conversation…
    ~ set_exit_knot(-> partir_brutalement)
    ** longue
    *** et pénible
    **** qui semble ne jamais devoir se terminer
    ***** mais va-t-il se taire ?
    ~ set_exit_knot(-> fuir)
    ****** …
    ******* il cause encore…
    ******** Ça y est, il a fini !
    ~ set_exit_knot(0)
    -> fin_demo


== partir_brutalement
Vous partez au milieu de la conversation, sans un mot.
-> END

== fuir
Vous faites demi-tour et partez en courant, loin de cet ennuyeux personnage.
-> END

== fin_demo
Mais vous n'avez pas testé la touche Échap…
-> END

Vous pouvez voir qu’on a une fonction externe set_exit_knot et qu’on l’appelle avec le noeud ink vers lequel il faudra aller (ou avec 0 pour désactiver la fonctionnalité lorsqu’on a le courage d’aller au bout de la conversation).

La première ligne, EXTERNAL set_exit_knot(knot), sert à dire à ink qu’il existe une fonction set_exit_knot qui prend un paramètre. La première chose que nous allons donc faire côté Calico, c’est de créer cette fonction et de la rendre accessible à ink. On ne voudrait quand même pas mentir à ink !

Si vous dites à ink qu’il existe une fonction externe mais qu’il n’y a pas réellement accès, vous aurez une erreur quand ink essaiera de l’appeler. Si vous testez votre jeu dans Inky, c’est ce qui va se passer. Vous pouvez définir dans votre source ink une fonction fallback, c’est-à-dire une fonction de secours qui sera appelée si ink ne trouve pas la fonction externe. En générale, on se contente d’une fonction qui ne fait rien, elle sert juste à ne pas provoquer d’erreur. Je vous renvoie à la documentation officielle pour les détails.

Déclarons donc cette fonction externe set_exit_knot(knot) :

// Une variable pour stocker le nom du noeud vers lequel il faudra aller.
let exit_knot;

ExternalFunctions.add("set_exit_knot", (knot) => {
  if (knot === 0) {
    exit_knot = null;
  } else {
    exit_knot = String(knot); // nom du noeud
  }
});

Vous avez peut-être remarqué que l’on convertit knot en texte avec String(knot). C’est parce que knot est en réalité un objet complexe d’inkjs et que nous ne sommes intéressés que par son nom, ce que nous donne la conversion en texte (parce qu’inkjs est bien fait).

Avec ça, quand on appelle ~ set_exit_knot(-> partir_brutalement), la variable JavaScript exit_knot contiendra "partir_brutalement".

Occupons nous maintenant du raccourci clavier :

Shortcuts.add("Escape", () => {
  // ...
});

Pas grand chose à expliquer si ce n’est le nom de la touche. Il correspond à la propriété key du KeyboardEvent déclenché par le navigateur. Mais je vous rassure, vous n’avez pas besoin de comprendre cette dernière phrase ! Vous pouvez aller sur keycode.info, taper sur la touche qui vous intéresse et noter ce qui s’affiche dans la case “event.key”.

Et pour finir, il faut faire dérailler le train ink et l’envoyer directement sur notre noeud :

story.jumpTo(exit_knot);

Si on met tout ça ensemble dans un patch, on obtient :

Patches.add(function () {
  const story = this;

  let exit_knot;
  ExternalFunctions.add("set_exit_knot", (knot) => {
    if (knot === 0) {
      exit_knot = null;
    } else {
      exit_knot = String(knot); // nom du noeud
    }
  });

  Shortcuts.add(story.options.escape_shortcut, () => {
    if (exit_knot) {
      story.jumpTo(exit_knot);
    }
  });
});

Je voudrais… des choix au milieu du texte

Pour ce dernier patch, le plus complexe, je vous propose d’aller piocher chez Twine et de recréer la fonctionnalité suivante : pouvoir avoir des choix au milieu du texte. Ça vous permettra d’avoir des jeux ink différents parce que tout le monde pense que “ink = choix à la fin”.

De quoi a-t-on besoin pour ça ? Il nous faut :

  • inventer une syntaxe pour signifier un choix au milieu d’un paragraphe
  • un mécanisme pour détecter cette syntaxe et la transformer en lien
  • un moyen de cacher le choix de la liste à la fin

Pour la syntaxe, je vous propose de reprendre la convention des crochets []. Par exemple :

Le vortex temporel vous recrache : vous êtes maintenant devant [la maison] de votre enfance. À droite, [un chemin] mène, si vous vous rappelez bien, à un minuscule parc avec un toboggan. 
* [la maison] Sans réfléchir à l'effet que le vous de 2023 va faire à vos parents de 1992, vous poussez la porte d'entrée. -> maison_1992 
* [un chemin] Aussi tenté que vous soyez de revoir vos parents, vous savez que ce n'est pas raisonnable de jouer ainsi avec le temps. Vous contournez la maison par la droite et rejoignez le parc. -> parc_1992

Pour ne pas répéter les choix longs, j’ajoute également cette variante : [identifiant:texte du choix]. Ça donnerait ça :

Le vortex temporel vous recrache : vous êtes maintenant devant [la maison] de votre enfance. À droite, [chemin:un chemin bordé de trèfles] mène, si vous vous rappelez bien, à un minuscule parc avec un toboggan. 
* [la maison] ... -> maison_1992 
* [chemin] ... -> parc_1992

Pour ce qui est de détecter cette syntaxe, nous allons utiliser une fonction de Calico : Parser.pattern(regexp, callback). Elle prend en paramètre une expression régulière et une fonction appelée quand l’expression est trouvée dans le texte produit par ink, avec pour seul argument line qui représente le paragraphe et contient plusieurs éléments utiles : line.text, le texte du pagraphe, line.tags, les tags et lines.classes qui permet d’ajouter des classes CSS.

Par exemple, on peut rendre rouge tous les mots qui finissent en “-ouge” :

const re = RegExp(/\b(\w*ouge)\b/gi);
Parser.pattern(re, (line) => {
  line.text = line.text.replaceAll(re, (match) => `<span style="color:red">${match}</span>`)
});

Pour l’instant (janvier 2023), un bug Calico fait que celui-ci ignore les patterns si un paragraphe n’a pas de tag. Vous aurez donc peut-être besoin d’ajouter un tag quelconque (même qui n’existe pas) à votre paragraphe.

Enfin, pour cacher le choix original, on se contentera d’un display: none et on supprimera animation et délai d’apparition.

Ce patch est assez compliqué alors nous allons d’abord regarder la logique avant d’attaquer le code.

Quand un paragraphe contient "[texte choix]" ou "[id:texte choix]"
    alors on remplace ça par le code html pour créer un lien inerte
    puis quand le paragraphe est ajouté dans la page
        alors on récupère une référence DOM vers lien et on la mémorise

Quand un choix est ajouté à la page
    alors on regarde s'il correspond à un lien mémorisé
    si oui, on masque le choix 
            et on rend le lien valide (apparence et clic = faire le choix)

Quand un choix est fait
    alors on rend tous les liens inertes et on les oublie

Place au code maintenant :

Patches.add(function () {
  const story = this;

  // Une expression régulière qui détecte [choiceText:linkText] ou [choiceText]
  const choiceRegExp = RegExp(/\[([^:\]]+)(?:\:([^\]]+))?\]/g);

  // Un objet pour stocker les références vers les liens
  let currentPassageChoicesInText = {};

  // On demande à Calico de détecter notre syntaxe
  Parser.pattern(choiceRegExp, (line) => {
    // Et on remplace toutes les occurrences par des liens
    line.text = line.text.replaceAll(
      choiceRegExp,
      (str, choiceText, linkText = choiceText) =>
        `<a data-choice="${choiceText}">${linkText}</a>`
    );
    // Calico ne nous donne qu'un objet "line" dans la callback de Parser.pattern(), on n'a pas encore accès au noeud DOM du paragraphe.
    // Pour contourner ça, on demande à être prévenu lors du prochain ajout d'un paragraphe dans la page.
    // Avec {once: true}, on s'assure de n'être appelé que pour un paragraphe, le prochain, c'est à dire celui qui nous intéresse.
    story.outerdiv.addEventListener(
      "passage line element",
      (ev) => {
        const p = ev.detail.element;
        // C'est ici qu'on récupère les références DOM vers les liens.
        const links = Array.from(p.querySelectorAll("[data-choice]"));
        for (const link of links) {
          // On les mémorise via leur 'choiceText' qu'on a rangé dans l'attribut data-choice.
          currentPassageChoicesInText[link.dataset.choice] = link;
        }
      },
      { once: true }
    );
  });

  // Quand un choix est ajouté, on vérifie s'il correspond à un lien.
  // Si oui, on le cache et on active le lien.
  story.outerdiv.addEventListener("passage choice element", (ev) => {
    const { element, choice } = ev.detail;
    const linkInText = currentPassageChoicesInText[choice.text];
    if (linkInText) {
      // Donnons au lien l'apparence d'un choix.
      linkInText.classList.add("choice");
      linkInText.style.cursor = "pointer";
      // Et rendons le cliquable. Au clic, on effectue le choix.
      linkInText.onclick = () => {
        story.choose(choice, linkInText);
      };
      // Et on cache le choix original.
      element.style.display = "none";
      element.delay = 0;
      element.animation = null;
    }
  });

  // Quand un choix est sélectionné, on désactive et on oublie les liens.
  story.outerdiv.addEventListener("passage end", (ev) => {
    for (const linkInText of Object.values(currentPassageChoicesInText)) {
      linkInText.onclick = null;
      linkInText.classList.remove("choice");
      linkInText.style.cursor = "default";
    }
    currentPassageChoicesInText = {};
  });
});

Publier votre patch

Vous venez d’écrire le patch parfait et vous voulez le partager ? Lisez ces quelques lignes pour le rendre encore meilleur !

Voici trois axes d’améliorations :

  • prévoir des options documentées pour généraliser votre patch ;
  • inclure des crédits, la licence logicielle de votre code et de l’éventuel code tiers que vous utilisez ;
  • exporter des fonctions réutilisables par d’autres développeurs de patches.

Des options documentées

Calico prévoit un mécanisme d’options pour donner un peu de souplesse à votre patch.

Il vous faut déclarer un objet options et le passer en deuxième argument à Patches.add(). En ce qui concerne la documentation, un commentaire à côté de l’option suffit amplement, c’est le premier endroit où l’utilisateur d’un patch la cherchera.

Ici, on imagine une option pour le patch de l’exemple “Choix dans le texte” qui permet de changer la syntaxe détectée. Si la syntaxe ne plait pas ou surtout entre en conflit avec un autre patch, c’est un bon moyen de permettre à l’utilisateur avancé de s’en sortir.

const options = {
  // l'expression régulière pour détecter un choix dans le texte
  choices_in_text_regexp: RegExp(/\[([^:\]]+)(?:\:([^\]]+))?\]/g)
};

Patches.add(function () {
  // ...
}, options);

Ensuite, dans le reste du code de votre patch, il y a une subtilité importante : il faut utiliser story.options.choices_in_text_regexp et pas juste options.choices_in_text_regexp. Pourquoi ? Parce que Calico recopie toutes vos options dans un objet options global et partagé. Ainsi, l’utilisateur modifie toutes les options au même endroit :

options.audioplayer_allowmultipletracks = true;
options.dragtoscroll_verticalmodifier = 0.7;
options.choices_in_text_regexp = /* une autre regexp */

const story = new Story("story.ink");

C’est aussi pour cette raison qu’il est vivement recommandé de préfixer toutes ses options par le nom du patch, on évite ainsi les conflits.

Des crédits

Si vous avez utilisé Calico avec la console de votre navigateur ouverte, vous avez peut-être remarqué qu’il s’y affiche ceci :

La console du navigateur affiche deux lignes. La première est "emoji chat Calico 2.0.1". La seconde est "emoji bobine de fil Patches".

Vous pouvez ouvrir ces “menus” en cliquant dessus et obtenir des détails :

La console du navigateur affiche deux menus dépliés. Le premier donne des infos sur Calico et ses dépendances (ink et inkjs). Le second donne des infos sur les patches.

On y trouve les descriptions, auteurs et licences de Calico, des patches et de leurs dépendances.

Pour que votre patch contribue à ce menu, il faut créer un objet credits et le passer en troisième arguments à Patches.add().

const credits = {
  emoji: "⌨️",
  name: "Typewriter effect",
  author: "Florian Cargoët",
  version: "1.0",
  description: "Adds a typewriter text effect.",
  licences: {
    self: "2022",
    mit: {
      typewriter: "2016-2017 Tameem Safi"
    }
  },
};

const options = {};

Patches.add(function () {
}, options, credits);

Le champ licences permet de choisir une licence pour son code et de préciser les licences des dépendances s’il y en a. Il y a des raccourcis si votre licence est MIT ou ISC. Ce n’est pas très intuitif alors je vous renvoie à la documentation officielle pour les détails.

Des exports

Vous l’avez vu dans l’exemple “Checkpoints”, on se sert des fonctions de memorycard pour construire notre patch. Ça n’aurait pas été possible si memorycard n’avait pas exporté ces fonctions explicitement.

Pour faire de même dans votre patch s’il propose des fonctions utiles, ajoutez à la fin :

export default { maFonction, monAutreSuperFonction };

Avec cette ligne, un utilisateur pourra importer votre patch :

import superpatch from "./patches/superpatch.js";
superpatch.maFonction();

Il y a d’autres façons de faire des exports et on peut exporter autre chose que des fonctions mais pour ne pas devenir un tutoriel JavaScript, cet article se limite à cette façon de faire, qui est celle de Calico.

-> END

Avec ces exemples, nous avons couvert ensemble une bonne partie de ce que propose Calico. L’article est déjà long, j’ai du faire des choix et mettre de côté quelques éléments :

  • le concept de queue : Calico place tous les paragraphes et choix dans une file d’attente de rendu et on peut s’en servir pour injecter ou modifier du contenu. Je m’en sers dans les exemples bonus “modifier du texte existant” et “choix images”;
  • la fonction addFileType qui est très utile si vos tags font référence à des fichiers. Je m’en sers dans l’exemple bonus “choix images”;
  • la fonction getTagOptions qui permet de gérer des options de tags avec une syntaxe prédéfinie par Calico. Ça donne des tags comme # play: act4 >> delay: 500 et c’est Calico qui s’occupe de parser tout ça.

Dans la démo et dans le dépôt de code, vous retrouverez ces deux exemples bonus :

  • Je voudrais… que mes choix soient des images
  • Je voudrais… modifier du texte existant quand on fait un choix

J’espère vous avoir donner envie de vous essayer à la création de patches, n’hésitez pas à partager vos créations sur notre Discord ou à y demander de l’aide !