|
JustAnotherDnDGame 0.1.0
Jeu de rôle tactique au d20, vue de dessus, en C++/Qt
|
Cette page explique à quoi sert un système de journalisation (« logging ») dans un jeu, et comment celui de ce moteur est construit. Tout vit dans Source/Core/Diagnostics, plus un petit en-tête de macros par module (Source/Core/CoreLog.h, Source/HMI/HmiLog.h, Source/HMI/Graphics/ GraphicsLog.h, …).
Un jeu compilé en Release, lancé par un joueur, n'a pas de console attachée ni de débogueur : impossible d'y poser un point d'arrêt ou d'inspecter une variable en direct. Si un comportement anormal survient — un niveau ne se charge pas, une ressource graphique échoue à se créer — la seule trace disponible après coup est ce que le programme a explicitement enregistré pendant son exécution. La journalisation consiste précisément à écrire, au fil de l'exécution, des messages textuels horodatés décrivant les événements significatifs (démarrage, chargement d'un niveau, erreur récupérable…), pour reconstituer après coup ce qui s'est passé sans avoir dû le prévoir en amont dans un débogueur.
C'est un outil différent d'une assertion (voir la dernière section) : le journal enregistre des faits pour un usage ultérieur et humain (diagnostic après coup), sans jamais interrompre le programme ; une assertion vérifie un invariant immédiatement et signale un bug s'il est violé.
Tous les messages ne se valent pas : un message peut être un détail de mise au point sans intérêt en usage normal, ou au contraire signaler une erreur qu'il faut voir absolument. core::LogLevel distingue quatre niveaux, en gravité croissante :
| Niveau | Usage |
|---|---|
| Trace | Détails fins de débogage — verbeux, utile en cours de développement d'une fonctionnalité précise. |
| Info | Fonctionnement normal — démarrage, niveau chargé, ressource créée. |
| Warning | Anomalie non bloquante — le jeu continue, mais quelque chose méritait attention. |
| Error | Erreur nécessitant attention — un échec qui affecte le déroulement. |
Le fait que ces niveaux soient ordonnés permet de filtrer : en développement, on veut généralement tout voir (Trace et au-dessus) ; pour une session de test plus ciblée, ne garder que Warning et Error évite de noyer les messages importants dans le bruit des détails Trace/Info. C'est le rôle du Logger, ci-dessous.
core::Logger a exactement deux responsabilités, séparées volontairement de toute écriture réelle sur un support (fichier, console, etc.) :
Le Logger ne fait aucune entrée-sortie lui-même — il ne sait pas écrire sur un fichier, une console, ou ailleurs. C'est une décision de conception délibérée : séparer « décider si un message doit être vu » (le Logger) de « où et comment l'écrire réellement » (les sinks) permet de changer librement les destinations sans toucher au code qui produit les messages, et de tester le filtrage sans jamais toucher un vrai fichier ou une vraie console (voir MemoryLogSink plus bas).
Une instance unique, globale, est accessible via core::defaultLogger() — c'est elle que les macros de journalisation (voir plus bas) utilisent implicitement, pour éviter d'avoir à faire transiter une référence de Logger à travers tout le code applicatif.
Un sink (« puits », au sens d'un point de destination) implémente l'interface core::ILogSink — une seule méthode, write(level, message). Deux implémentations existent :
Comme le Logger possède ses sinks (std::unique_ptr, RAII), ajouter un nouveau type de destination (par exemple, plus tard, un fichier sur disque) revient à écrire une nouvelle classe implémentant ILogSink, sans toucher au Logger ni au code qui journalise.
Écrire core::defaultLogger().log(core::LogLevel::Info, "...") à chaque site d'appel serait verbeux et n'indiquerait pas d'où vient le message. Core/Diagnostics/Log.h définit une macro générique, JADG_LOG(catégorie, niveau, message), et ses quatre raccourcis JADG_LOG_TRACE/INFO/WARNING/ERROR(catégorie, message).
Pourquoi une macro plutôt qu'une fonction. Deux raisons concrètes :
Plutôt que de répéter une chaîne littérale "HMI" ou "Core" à chaque site d'appel (source d'erreurs de frappe et de récriture répétitive), chaque module définit son propre petit en-tête de raccourcis — le modèle à dupliquer pour un nouveau module est HMI/HmiLog.h :
Core/CoreLog.h (catégorie "Core"), Core/Ecs/EcsLog.h ("Ecs"), Core/Gameplay/GameplayLog.h ("Gameplay"), Core/Levels/LevelsLog.h ("Levels"), HMI/Graphics/GraphicsLog.h ("Graphics"), HMI/Platform/PlatformLog.h ("Platform") et HMI/Editor/EditorLog.h ("Editor") suivent exactement le même modèle. La catégorie apparaît ensuite dans chaque ligne journalisée (voir le format ci-dessous), ce qui permet, en lisant un journal, de savoir immédiatement quel sous-système a émis un message donné — utile dès qu'un jeu grandit au-delà de quelques fichiers, et pour filtrer un journal verbeux en ne gardant que la catégorie qui intéresse un diagnostic précis (par exemple grep "\[Gameplay\]" pour ne voir que les bascules de mécanismes).
Le commentaire de Core/CoreLog.h le rappelle explicitement : ces macros sont à réserver aux événements de cycle de vie (démarrage, chargement d'un niveau, création d'une ressource) — jamais dans un chemin exécuté à chaque frame ou à chaque pas fixe (Boucle de jeu et pas de temps fixe). Un jeu tourne à 60 pas par seconde ; journaliser à cette fréquence, même avec un niveau filtré, resterait coûteux (construction de chaînes, appel de fonction, éventuelle écriture) et pourrait à lui seul dégrader le framerate — le symptôme inverse de ce que la journalisation est censée aider à diagnostiquer.
core::formatLogLine(timestamp, level, category, file, line, message) compose une ligne de la forme :
Deux détails valent d'être notés :
Le niveau minimal du Logger n'est pas figé dans le code : core::parseLogLevel(text) (Core/Diagnostics/LogLevelParse.h) convertit une chaîne (« trace », « info », « warning »/« warn », « error », insensible à la casse) en LogLevel, ce qui permet de le régler au lancement sans recompiler. Dans ce moteur (Source/HMI/Main.cpp), deux sources sont acceptées, avec priorité à la seconde si les deux sont présentes :
Une valeur non reconnue par parseLogLevel (renvoyant std::nullopt) est ignorée plutôt que de faire échouer le démarrage — journaliser un Warning à ce sujet est préférable à interrompre le jeu pour une simple faute de frappe dans un paramètre de diagnostic.
main() illustre bien pourquoi séparer Logger (filtrage) et sinks (destination) est utile en pratique : en build de développement (core::kDeveloperBuild, dérivé de NDEBUG), un ConsoleLogSink et un MemoryLogSink sont enregistrés (le second alimentant le bouton d'enregistrement de session évoqué plus haut) ; en Release, aucun sink n'est ajouté — l'exécutable Release n'a pas de console (sous-système Windows pur) et il serait inutile de faire grandir indéfiniment un tampon mémoire qu'aucune interface de développement n'exploite. Le code qui journalise, lui, reste identique dans les deux configurations : HMI_LOG_INFO("Demarrage...") s'exécute pareil des deux côtés — c'est le nombre de sinks enregistrés, décidé une fois au démarrage, qui change ce qu'il en advient.
Le MemoryLogSink (Core) collecte ces entrées en mémoire en build développement ; les écrire sur disque relève de la couche présentation. L'onglet Général de la page Options Qt expose un bouton « Enregistrer les journaux » : hmi::saveSessionLog (Source/HMI/Diagnostics/SessionLog.h, logique pure testée) sérialise MemoryLogSink::entries() dans un fichier horodaté (Logs/), sans toucher à ce que Core a déjà collecté. En Release, aucun sink mémoire n'est enregistré : le bouton signale simplement des journaux indisponibles.
Une assertion vérifie qu'une condition, censée être toujours vraie si le code est correct (une précondition, un invariant), l'est effectivement à un point précis de l'exécution — sa violation signale un bug dans le programme lui-même, pas un événement à consigner pour information. C'est déjà ce que ComponentPool/World utilisent abondamment (ECS : entités, composants, systèmes) : JADG_ASSERT(has(entity), "...") avant d'accéder à un composant, par exemple.
Deux différences fondamentales avec la journalisation :
En résumé : journaliser un événement (« le niveau 3 a été chargé ») documente un fait pour un humain qui lira le journal plus tard ; asserter une condition (« cette entité doit être vivante ici ») protège contre un bug du code, et n'a de sens qu'en développement.