Technique
Concevoir le moteur de navigation par graphe

Sous le capot de la navigation du lecteur : des cases comme nœuds, des arêtes conditionnelles, un moteur de déroulement, un contexte de variables aplati, du JSON Logic qui échoue en mode fermé, des priorités, des transitions et mouvements de caméra hérités, le retour en arrière, et l’analyse de graphe que réutilise le CMS.
Chaque chapitre PanelWave est un graphe orienté : les cases sont des nœuds, et les arêtes disent quelle case peut suivre laquelle, sous quelle condition et avec quelle transition. Le format définit cette structure ; au PanelWave Player de la parcourir, des milliers de fois par session de lecture, sans jamais laisser personne en plan. Ce billet porte sur la partie qui fait ce travail : le moteur de déroulement, le stock de variables qu’il consulte, et les choix de conception derrière les deux. Pour l’introduction conceptuelle, lisez d’abord Navigation par graphe ; cet article la suppose acquise.
Pourquoi un graphe, et pourquoi un moteur minuscule
Sans graphe, il faut un ordre de pages plus des cas particuliers : une page de décision ici, une table de sauts là. Nous voulions l’inverse : une seule primitive, qui rende les histoires linéaires gratuites et les embranchements bon marché. Une BD classique ? Une chaîne d’arêtes sans condition. Un choix ? Deux arêtes avec des conditions différentes. Une boucle, un détour, une scène facultative, un chemin qui rejoint la ligne principale : autant de formes de graphe ordinaires, et le retour en arrière fonctionne de la même façon pour toutes.
Grâce à ce choix, le moteur est resté petit. Le moteur de déroulement est un service pur, sans état propre : donnez-lui un graphe, l’identifiant de la case courante et un objet de contexte, il renvoie où aller ensuite. Tout le reste, de la position de lecture au contenu des variables, vit dans d’autres services. Il a été écrit avec 43 tests unitaires avant même qu’existe le moindre rendu, et reste la partie du lecteur qui change le moins. L’API du service est listée sous Services principaux.
Les quatre étapes d’une navigation
Quand on avance, que ce soit par une touche, un balayage, la lecture automatique ou une zone interactive, le shell demande au moteur la case suivante. Le moteur fait exactement quatre choses, et l’ordre compte :
Le diagramme de séquence et les règles exactes sont sur État, conditions et navigation. Cette conception a deux conséquences. Les auteurs obtiennent un repli gratuit : placez une arête sans condition, de priorité zéro, à côté des arêtes conditionnelles, et quiconque avance sans choisir continue quand même. Et le moteur n’a jamais besoin de savoir ce que signifie une condition ; il lui suffit de savoir si elle est remplie.
- Rassembler toutes les arêtes dont le from est la case courante. S’il n’y en a aucune, la case est un point final, et le moteur le dit au lieu de deviner.
- Filtrer les candidates en évaluant la condition JSON Logic de chaque arête par rapport au contexte. Une arête sans condition passe toujours.
- Trier les arêtes restantes par priorité, de la plus haute à la plus basse ; une priorité absente vaut zéro. À priorité égale, l’ordre du manifeste départage.
- Renvoyer la cible de la première arête restante, avec sa transition et ses actions d’arête. S’il n’en reste aucune, le résultat est là encore une cible nulle.
const candidates = graph.edges.filter((e) => e.from === currentPanelId);
const passing = candidates.filter((e) => evaluateJsonLogic(e.condition, context));
const winner = [...passing].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))[0];
return winner
? { nextPanelId: winner.to, transition: winner.transition ?? defaultTransition, action: winner.action }
: { nextPanelId: null };Des conditions qui échouent en mode fermé
Les conditions sont en JSON Logic, évaluées par une fine surcouche autour de json-logic-js. Trois règles de cette surcouche ont demandé plus de réflexion que le code ne le laisse penser. Une condition absente vaut vrai, car une arête sans condition doit toujours être empruntable. Un booléen littéral est renvoyé tel quel : un manifeste peut ainsi activer ou désactiver une arête en dur pendant la création. Et toute erreur d’évaluation (opérateur inconnu, logique malformée, exception levée) est interceptée, journalisée et renvoyée comme faux.
Échouer en mode fermé a été le choix le plus débattu. En mode ouvert, une condition cassée se comporterait comme une arête de repli et masquerait les erreurs de création jusqu’à ce que quelqu’un prenne un chemin imprévu. En mode fermé, l’arête cassée disparaît, ce que la validation du CMS et le mode Simulate de l’éditeur de graphe révèlent aussitôt. Nous avons préféré une erreur bruyante dans l’éditeur à une erreur silencieuse en production. Le même évaluateur sert pour toutes les conditions du format : arêtes, conditions de visibilité des calques, des bulles et des zones interactives, et variantes de case ; une règle apprise une fois vaut partout. La surcouche ajoute aussi des opérateurs pratiques comme in, contains, matches, between et isEmpty, ainsi qu’une fonction qui extrait toutes les références de variables d’une règle : les outils peuvent ainsi lister ce dont dépend un chapitre.
Cinq portées, un contexte à plat
Les conditions ne voient jamais directement le stock de variables. Le stock range les valeurs dans cinq portées conformes au schéma (global, chapitre, page, session et persistant) ; juste avant une navigation, le shell lui demande de les aplatir en un seul objet. L’ordre de fusion est délibéré : global d’abord, puis session, persistant, la portée du chapitre courant et enfin celle de la page courante ; en cas de collision de clés, la dernière l’emporte. Une variable de chapitre masque donc une variable globale de même nom tant qu’on reste dans ce chapitre : exactement ce qu’il faut pour l’état d’une énigme propre à un chapitre. Les identifiants à espaces de noms séparés par des points, comme story.choice, deviennent des chemins imbriqués dans le contexte : l’opérateur var de JSON Logic les résout sans traitement particulier.
Le stock valide chaque écriture d’après les définitions du manifeste : type, plage, appartenance à l’énumération et indicateur de lecture seule. Une écriture invalide est abandonnée avec un avertissement, plutôt que de corrompre l’état. Seule la portée persistante touche à localStorage, avec un repli en mémoire quand le stockage n’est pas disponible. Le tableau des portées et les règles d’écriture sont sur État, conditions et navigation ; les détails de persistance, dans Sauvegarder la progression.
Ce qui voyage avec l’arête : transitions et mouvements de caméra
Une arête ne dit pas seulement où aller ; elle dit aussi comment y aller. Depuis le schéma 1.2, les arêtes héritent d’une transition par défaut du préréglage de sortie actif : les auteurs règlent fade-300ms une fois par format, et non sur chaque arête. Le moteur résout lui-même cet héritage : la transition propre à l’arête l’emporte, sinon c’est celle du préréglage. Quand aucun format n’est actif, la valeur par défaut ne s’applique que si tous les préréglages définis s’accordent sur elle ; en dernier recours, c’est une coupe franche. Cette règle est volontairement prudente : mieux vaut ne pas animer que deviner le format et animer de travers.
Le schéma 1.4 a ajouté le canevas infini, et avec lui un deuxième passager de l’arête : le mouvement de caméra. Le moteur le traite exactement comme une transition (valeur de l’arête d’abord, valeur par défaut du préréglage ensuite) et le renvoie avec la cible pour que la vue canevas puisse glisser. Trajectoire directe, en arc ou par points de passage, profil de zoom maintien, recul ou plongée : le moteur de déroulement n’interprète rien de tout cela, il le transmet. La sémantique de navigation est identique dans tous les modes d’affichage, et c’est grâce à cette propriété que la vue canevas a pu arriver sans toucher à la règle de sélection. Voir Vue canevas et la référence du format canevas.
Revenir en arrière : la même arête, à l’envers
« Précédent » ne dépile pas un historique. Le moteur cherche les arêtes qui pointent vers la case courante et renvoie leurs sources ; le shell prend la première. Pour l’animation, il retrouve l’arête empruntée à l’aller, de la case précédente à la case courante, et l’inverse : slide-left devient slide-right, up devient down, et les transitions sans direction comme fade ou zoom restent inchangées. Les mouvements de caméra du canevas s’inversent de la même façon, points de passage compris : la caméra refait le chemin prévu au lieu de se téléporter. Le retour en arrière reste ainsi fidèle à ce que l’auteur a conçu, sans journal d’animations à stocker par session.
La boîte à outils d’analyse, et qui s’en sert
Avancer d’un pas n’est qu’une méthode ; l’essentiel du moteur, c’est de l’analyse de graphe qui ignore volontairement les conditions. Des fonctions vérifient si une case est une entrée ou un point final, trouvent tous les points finaux, testent par un parcours en largeur à profondeur bornée si une case est atteignable, cherchent le plus court chemin entre deux cases, détectent les cycles par un parcours en profondeur avec pile de récursion, et listent toutes les cases atteignables depuis un nœud de départ. Pour les questions de structure, ignorer les conditions est le bon choix par défaut : un auteur qui demande si une case peut être atteinte veut connaître le graphe, pas la valeur actuelle d’une variable.
Le CMS parle le même langage. Le Graph Editor signale nœuds inaccessibles, cycles, nœuds isolés et impasses avec ses propres parcours en largeur et en profondeur, dispose la carte avec dagre, met les problèmes en évidence sur le canevas et propose des corrections rapides, comme relier un nœud orphelin à l’entrée. Son mode Simulate parcourt la structure comme les fonctions d’analyse du moteur, en listant chaque arête sortante avec sa condition. Avant chaque publication, la validation Preflight vérifie à nouveau, sur toute l’œuvre, que chaque case est atteignable. Pour tester les conditions avec de vraies valeurs, passez par Preview : les surcharges de variables y alimentent le même contexte aplati que voit le moteur.
Les droits d’accès restent hors du graphe
La monétisation aurait pu prendre la forme de conditions sur les arêtes. Nous l’avons volontairement tenue hors du graphe. Savoir si quelqu’un peut entrer dans une case, c’est l’affaire d’un adaptateur de droits d’accès implémenté par l’hôte, attendu avant chaque navigation, avec un cache de courte durée dans le service des droits d’accès. Le moteur expose des passerelles pour vérifier une case, un chapitre ou une œuvre, mais le graphe reste une description de l’histoire, pas de qui l’a payée. Les faits vérifiés que connaît l’hôte, comme un droit d’accès ou l’âge d’un utilisateur, peuvent tout de même être injectés au démarrage dans des variables en lecture seule : les conditions peuvent y faire référence, mais le contenu de l’histoire ne peut pas les modifier. Voir Paywall et droits d’accès.
Des compromis que nous referions, et un que nous revoyons
- Moteur pur, shell avec état. Chaque navigation est une fonction du graphe, de la position et du contexte. Résultat : un moteur trivial à tester, et un CMS qui peut exécuter la même logique dans son simulateur.
- Les conditions dans les données, pas dans le code. JSON Logic est assez petit pour être écrit à la main et assez sûr pour être évalué depuis des manifestes non fiables. Des opérateurs personnalisés existent, mais rien dans le format de base n’en a besoin.
- Structure et état, deux questions distinctes. Les fonctions d’analyse ignorent les conditions ; la fonction d’avancement les respecte. Mélanger les deux fausserait une partie du temps les deux réponses.
- Les actions d’arête sont renvoyées, pas appliquées. Le moteur renvoie les mutations de variables d’une arête dans le résultat de navigation et laisse l’hôte les appliquer. Il reste ainsi sans effets de bord, mais le shell ne les applique pas encore automatiquement : c’est le point de cette liste que nous revoyons. Le comportement actuel est documenté sous Mutations.
Pour aller plus loin
- Graphe : chaque propriété d’un graphe, d’une arête et d’une mutation, avec des notes de validation.
- Architecture du lecteur : la place du moteur dans le flux de données du manifeste au rendu.
- Variables : le modèle d’état dont le contexte est aplati.
- Des récits à embranchements sans plat de spaghettis : les principes de création qui tirent le meilleur parti de tout ce qui précède.
