Architecture du Player

Pourquoi le Player Romergo fonctionne avec le DOM ordinaire

Un moteur de visual novel est généralement moins complexe que celui d'un jeu de tir en 3D ou d'un jeu de stratégie en temps réel. Il n'a pas besoin de calculer chaque seconde la physique d'un vaste monde, le comportement de centaines d'objets et un éclairage complexe. Mais cela ne signifie pas qu'il suffit de remplacer des images au clic.

Une scène classique de visual novel se compose d'un ensemble assez clair :

Toute la magie vient de la modification de ces paramètres au fil du temps. Il ne suffit pas de savoir quoi afficher : il faut aussi décider quand l'afficher, ce qui doit rester inchangé, comment poursuivre la musique entre les scènes, quelle langue utiliser et où aller après l'action du joueur.

J'ai longtemps cherché un moteur avant de choisir le navigateur

J'ai essayé de construire la scène avec Godot, PixiJS et Three.js. Avec des résultats variables, tout cela fonctionnait, et les moteurs eux-mêmes n'étaient pas en cause. Mais pour ma manière de créer des quêtes, ils ajoutaient une couche avec laquelle je devais composer séparément.

Je n'avais pas seulement besoin d'un écran de jeu terminé. Je voulais voir instantanément chaque modification dans Builder, lancer une seule scène sans construire tout le jeu, sélectionner et déplacer des éléments directement dans l'aperçu, puis retrouver le même comportement dans le Player publié.

Finalement, la solution la plus efficace s'est révélée être la plus simple : le DOM ordinaire du navigateur.

L'arrière-plan, les personnages et le dialogue restent des couches classiques du navigateur. La majeure partie de la composition et des effets repose sur CSS. Canvas 2D sert à certains éléments visuels, tandis qu'un canvas WebGL est utilisé là où des transitions par shaders entre les images sont réellement nécessaires.

Le son fonctionne avec l'HTMLAudioElement du navigateur. Le runtime crée l'audio des clips actifs, contrôle le volume et la lecture en boucle, et conserve la même musique pendant une transition si son asset n'a pas changé.

Ce n'est pas une recette universelle pour tous les jeux. Mais pour un visual novel, le DOM n'a pas été un compromis : il s'est révélé être un outil très précis.

Une scène simple et un runtime intelligent

Dans mon esprit, le Player se divise en deux parties :

La scène ne doit pas savoir pourquoi un personnage donné est apparu, quelle condition a débloqué une réponse ni quel chapitre viendra ensuite. Elle reçoit les clips, l'heure actuelle, la langue, le mode d'écran et les références des médias. Elle affiche ensuite le résultat et renvoie les actions du joueur.

Le runtime reçoit la description de la quête et l'état de la partie. Il connaît le chapitre, le nœud et la scène actuels, les valeurs des variables, les choix effectués, les points de contrôle et les chapitres terminés. Lorsque le joueur clique sur la scène ou choisit une réponse, le runtime applique les effets, trouve l'étape suivante et fournit à nouveau un état prêt à afficher.

En simplifiant fortement, la boucle ressemble à ceci :

JSON de la quête + sauvegarde
          ↓
le runtime détermine l'étape actuelle
          ↓
la scène affiche les clips et les médias
          ↓
l'action du joueur revient au runtime
          ↓
étape suivante — la boucle continue jusqu'à la fin

Dans un jeu publié, le Player charge un instantané figé du runtime de toute la quête. Dans Builder, l'aperçu construit un runtime compatible à partir du brouillon actuel. Il ne s'agit donc pas de deux players similaires qui finissent par se comporter différemment, mais d'un même RuntimePlayer, SceneStage et viewport placés dans des enveloppes différentes.

Comment la continuité est préservée entre les scènes

Lors d'une transition, le runtime recalcule l'état de la nouvelle scène. Une musique répétée est reconnue par l'ID de son asset et continue sans redémarrage ni nouveau fade-in. Le navigateur charge et met les images en cache : réutiliser un arrière-plan ne signifie donc pas télécharger à nouveau le fichier depuis le réseau.

L'optimisation ne réside donc pas dans une grande condition « ne rien envoyer », mais aux bons niveaux : le runtime préserve la continuité de lecture, le resolver renvoie des ressources stables et le cache du navigateur ne retélécharge pas les médias déjà connus.

Deux écrans pour une même scène

La tâche la plus pénible n'a pas été le rendu lui-même, mais l'adaptation au téléphone. Réduire simplement une scène horizontale rend les personnages trop petits, comprime le dialogue et peut faire sortir du cadre des détails importants de l'arrière-plan.

Dans Romergo, une scène possède deux viewports virtuels fixes : un format horizontal de 1280 × 720 et un format vertical de 390 × 693. Le Player choisit le mode adapté selon l'appareil et son orientation, puis met à l'échelle la scène terminée dans son ensemble.

La disposition desktop reste la principale. Pour le téléphone, on peut définir des positions et une échelle distinctes pour les personnages ; en l'absence de remplacement, la version desktop est utilisée avec une réduction supplémentaire. L'auteur ne modifie donc pas deux scènes indépendantes, mais une seule scène avec des ajustements mobiles ciblés.

Pour l'arrière-plan, bgX, bgY et l'échelle sont communs aux deux modes, tandis que le viewport vertical recadre la même image à sa manière. Avant la publication, il faut donc vérifier le cadrage dans les deux formats et choisir un point focal commun. Des réglages d'arrière-plan mobiles séparés ne font pas partie du contrat du runtime.

Les ajustements répétitifs des personnages peuvent être confiés à un agent IA via MCP, puis vérifiés dans l'aperçu réel des deux viewports. C'est pour cela que je parle d'une adaptation semi-automatique plutôt que totalement automatique.

Comment éviter d'afficher un écran noir au joueur

Une scène simple ne sert à rien si l'image requise n'est pas encore arrivée par le réseau. Le chargement est donc lui aussi devenu une partie du contrat du runtime.

Avant le premier rendu, le Player rassemble les médias actifs de la scène initiale et attend leur chargement. Pendant ce temps, le joueur voit un véritable indicateur de progression, et non un fond vide. Après le démarrage, le runtime attend brièvement puis précharge les ressources de la scène actuelle et des scènes accessibles dans les deux prochaines étapes du graphe. Par défaut, la profondeur est de deux transitions, avec toutes les branches possibles à ces deux étapes.

Le préchargement suit le graphe des transitions et sa profondeur peut être réglée séparément. Il n'utilise pas un nombre fixe de scènes : une séquence linéaire et une bifurcation créent des charges différentes, même si le même nombre d'étapes se trouve formellement devant elles.

Un autre mode existe pour jouer hors ligne : l'utilisateur peut télécharger toute la quête à l'avance. L'instantané du runtime, les médias et l'enveloppe PWA restent alors dans le cache du navigateur et s'ouvrent sans connexion. Le préchargement de la scène suivante assure une partie en ligne fluide, tandis que le téléchargement complet fournit un véritable mode hors ligne.

Telegram comme enveloppe, Discord comme système distinct

Mon amour des navigateurs s'est montré particulièrement utile lors de l'intégration du Player à Telegram et Discord. Dans les deux cas, le même web runtime s'ouvre à l'intérieur de la plateforme : il n'a donc pas fallu réécrire la scène ni les règles de progression pour un nouveau moteur de jeu.

Telegram a surtout nécessité une session de plateforme, le lancement de la quête, une progression locale et la navigation entre la Mini App et le Player ordinaire. Le jeu lui-même est resté identique.

Discord est plus complexe, car plusieurs personnes doivent voir le même moment de l'histoire. Chaque participant exécute bien son propre runtime local, mais le host est la source de vérité. Son Player envoie l'état lors des changements et environ toutes les 750 millisecondes. La salle realtime reçoit la commande par WebSocket, enregistre l'instantané et le diffuse aux participants. Les runtimes des spectateurs appliquent l'état distant, restaurent la scène et poursuivent leur décompte local entre les synchronisations.

Cette séparation produit un résultat utile : l'état de l'histoire est partagé, tandis que la langue et la taille d'écran restent locales. Un participant peut regarder la scène en russe sur son téléphone, un autre en anglais sur un grand écran, et tous deux restent synchronisés avec le host. Les spectateurs peuvent aussi voter pour les réponses sans devenir un second host.

Une base unique pour tous les modes

Le Player a depuis longtemps dépassé l'ensemble de base « arrière-plan, personnages et réplique ». Il comprend désormais des embranchements, des variables, des points de contrôle, le retour en arrière, des scènes d'interface interactives, des traductions, le mode hors ligne et une progression synchronisée.

Mais la décision de base a résisté à cette croissance. La scène continue de gérer l'image et le son. Le runtime continue de gérer l'état et les transitions. Les API du navigateur couvrent le rendu, les médias, le cache et l'intégration, tandis que les couches spécialisées ne sont ajoutées que là où elles sont réellement indispensables.

Grâce à cela, le même Player peut être maintenu dans l'aperçu de Builder, les tests, le jeu ordinaire dans le navigateur, Telegram et Discord. Pour moi, c'est un bon exemple de la manière dont la solution la plus simple et un peu rudimentaire peut devenir la plus flexible, si la frontière des responsabilités est correctement tracée.

Retour au blog technique