|
JustAnotherDnDGame 0.1.0
Jeu de rôle tactique au d20, vue de dessus, en C++/Qt
|
Un niveau est une grille de tuiles typées plus des métadonnées (nom, entrée, sortie, mécanismes, budgets de mouvements). Tout vit dans Source/Core/Levels (le modèle en mémoire et son chargement) et Source/Core/Gameplay (le comportement des mécanismes).
Le moteur manipule deux représentations de position différentes, et le confondre est une source d'erreurs fréquente :
Comme une tuile = 1 unité monde, convertir l'un vers l'autre est une simple conversion de type (column devient x, row devient y) — mais les deux ne sont jamais interchangeables dans le code : une case n'a pas de position « à mi-chemin », un personnage si.
Chaque case de la grille a l'un de ces types :
| Type | Rôle |
|---|---|
| Empty | Case traversable, par défaut (aucun contenu). |
| Solid | Bloque le déplacement en toute circonstance — un mur ou le sol. |
| Danger | Traversable, mais son contact est mortel. Ce que devient la mort d'un personnage — agonie, jets de sauvegarde — est le sujet du LOT-72. |
| Entry | Position d'apparition du personnage au chargement du niveau. |
| Exit | Sortie de la carte. Ne termine plus rien depuis le LOT-67 ; redeviendra une transition vers une autre carte au LOT-09. |
| Switch | Interrupteur : son activation bascule l'état d'une Door liée (voir §« Mécanismes »). |
| PressurePlate | Plaque de pression : ouvre une Door liée tant qu'un poids y repose (EX-GP-025) — activation continue, pas de bascule. |
| Door | Porte : solide fermée, franchissable ouverte — son état dépend du Switch/PressurePlate lié. |
| Block | Bloc poussable (EX-GP-022) : solide comme un mur tant qu'il n'a pas bougé, mais peut être déplacé par le personnage et retombe sous gravité (voir §« Blocs poussables »). |
| SlopeUpRight | Pente à 45° (EX-GP-003) montant de gauche à droite : Empty pour toute autre logique (jamais solide), mais sa surface inclinée est suivie par la physique (Physique du personnage). |
| SlopeUpLeft | Symétrique de SlopeUpRight : pente montant de droite à gauche. |
| RoundedUpRight | Variante courbe (quart de cercle, EX-GP-004) de SlopeUpRight : même orientation, même suivi de surface, formule de hauteur différente. |
| RoundedUpLeft | Symétrique de RoundedUpRight : arrondi montant de droite à gauche. |
Notez que Door n'est pas statiquement solide au sens de core::isSolid(TileType) — sa solidité dépend de son état, calculé par le MechanismController (voir plus bas), pas du type de tuile seul. Les pentes et arrondis ne sont, eux, jamais solides (core::isSolid renvoie toujours faux) — voir Physique du personnage pour le mécanisme de suivi qui les rend praticables.
core::TileMap est une grille dense width × height de TileType, origine haut-gauche (même convention que tout le moteur, Mathématiques du moteur). Elle expose tile(colonne, ligne) (lecture) et isSolid(colonne, ligne) (utilisée directement par le balayage de collision, Physique du personnage §1). C'est une donnée pure, sans dépendance à un fichier ou à un rendu — testable isolément.
core::Level regroupe : un nom, une TileMap, une position d'entrée et de sortie (GridPosition), une liste de core::Mechanism (liaisons interrupteur↔porte, résolues en positions), des budgets de mouvements (jumpBudget/dashBudget, décrits plus bas) et un cadrage de caméra résolu (core::CameraFramingConfig, LOT-64, détaillé plus bas). C'est l'objet que le chargeur produit et que le reste du moteur (rendu, gameplay) consomme en lecture seule — la TileMap d'un Level ne change jamais après le chargement ; c'est le contrôleur de mécanismes (voir plus loin) qui maintient sa propre copie mutable pour représenter les portes en cours de partie.
Un niveau est décrit dans un fichier texte au format JSON ⧉, parsé par la bibliothèque nlohmann/json, dont l'usage est confiné au fichier .cpp du chargeur — le reste du moteur ne dépend jamais directement de cette bibliothèque. La classe core::LevelLoader expose deux points d'entrée statiques : loadFromFile (depuis un chemin) et loadFromString (depuis du texte déjà en mémoire, pratique pour les tests).
(exemple illustratif, inspiré de Source/Elements/Levels/demo-interrupteur.json — voir ce fichier pour la version réelle et complète). À lire ainsi :
Le chargement valide le contenu (EX-LVL-004) avant de produire un Level utilisable : dimensions cohérentes avec les tuiles listées, tuiles toutes dans les bornes de la grille, aucune case dupliquée dans la liste, exactement une tuile Entry et exactement une tuile Exit (un niveau sans sortie, ou avec deux entrées, est une erreur de contenu, pas une situation ambiguë à tolérer), et chaque liaison de mécanisme résolue (un opensWith qui référence un id inexistant est une erreur).
En cas d'échec — JSON malformé, champ manquant, type de tuile inconnu, échec d'une des validations ci-dessus — le chargeur ne lève jamais d'exception vers l'appelant (EX-NFR-040) : il renvoie un core::LevelLoadResult, une paire { optional<Level> level, std::string error }. ok() indique le succès ; en cas d'échec, level est vide et error décrit le problème de façon exploitable (affichable au joueur ou au développeur). Ce choix — résultat récupérable plutôt qu'exception — garde la gestion d'erreur explicite à chaque site d'appel, cohérent avec le reste du moteur qui ne s'appuie pas sur les exceptions pour son flux de contrôle normal.
Un champ racine optionnel, "cameraFraming", porte le mode de cadrage choisi par le level designer (EX-LVL-006) — la mécanique de ce cadrage (zone morte, anticipation, lissage du mode suivi) est détaillée dans Rendu 2D : de l'ECS à l'écran, cette section ne couvre que sa place dans le format :
mode vaut "wholeLevel", "perRoom" ou "follow" ; roomWidthTiles/roomHeightTiles sont lus en mode "perRoom" et "follow" (EX-REN-017 : la taille de vue du suivi réutilise les mêmes champs que la taille de salle) et restent optionnels dans les deux cas (taille par défaut si absents). Champ absent — tous les niveaux antérieurs à ce lot — le chargeur applique la règle de repli (core::resolveCameraFraming) : elle reproduit exactement le comportement historique ("wholeLevel" si le niveau tient dans une salle de taille par défaut, "perRoom" sinon), pour que la rétrocompatibilité des niveaux existants reste garantie (EX-LVL-005). C'est pour ce champ que le numéro de version de format est passé de 1 à 2 (core::kLevelFormatVersion) — le mécanisme de repli lui-même n'exigeait pas ce bump (un champ absent se lit déjà sans erreur), mais la convention du projet est de tracer chaque champ significatif ajouté au format.
core::LevelWriter n'émet le champ que si le cadrage diverge de ce que la règle de repli recalculerait pour les dimensions du niveau : un niveau jamais retouché sur ce point reste sans le champ après un aller-retour éditeur, exactement comme avant ce lot.
En mode "perRoom", un tableau optionnel "zones" (EX-LVL-007) porte des rectangles de caméra dessinés à la main par le level designer, chacun { "x", "y", "width", "height" } en tuiles :
Ce tableau permet de mélanger plusieurs tailles de caméra dans un même niveau, là où roomWidthTiles/roomHeightTiles seuls n'en autorisent qu'une, uniforme sur tout le niveau. La résolution de la zone active (la première de la liste couvrant la position du personnage) est un mécanisme de HMI, détaillé dans Rendu 2D : de l'ECS à l'écran — ce champ n'est ici qu'une donnée, absente par défaut : un niveau sans "zones" (ou avec un tableau vide) garde exactement le comportement de grille automatique roomWidthTiles/roomHeightTiles décrit ci-dessus.
Le TileMap d'un niveau n'est, en lui-même, qu'un tableau de types — il ne peut pas être affiché. core::buildLevelScene(world, level, régionParType) fait le pont vers le rendu, en projetant chaque tuile non vide en une entité ECS (ECS : entités, composants, systèmes) portant un Transform (position = colonne, ligne, converties en unités monde) et un Sprite (l'image à afficher, choisie par la correspondance régionParType injectée en paramètre plutôt que codée en dur — ce qui permet de tester la projection sans dépendre du rendu réel ou du GPU).
Point important : cette projection est à sens unique et n'a lieu qu'une fois, au chargement du niveau. Le TileMap reste la source de vérité pour tout ce qui touche à la collision — la physique (Physique du personnage) interroge directement isSolid(colonne, ligne), jamais les entités-sprites générées. Modifier une entité-sprite ne changerait donc rien à la collision ; c'est le mécanisme de portes ci-dessous qui montre comment un changement d'état du niveau doit réellement se propager.
core::MechanismController (logique pure, dans Core/Gameplay, sans dépendance rendu) donne un comportement aux liaisons déclencheur↔porte que le modèle de niveau ne fait que représenter (EX-GP-020, EX-GP-021, EX-GP-025). Deux types de déclencheur, deux comportements — la nature de chacun est figée une fois pour toutes à la construction, d'après la tuile d'origine (TileType::Switch ou TileType::PressurePlate à sa position) :
Concrètement, avec l'exemple JSON ci-dessus : la porte en (7, 6) est solide tant que l'interrupteur s1 en (3, 6) n'a pas été touché ; dès que le personnage marche dessus, elle devient franchissable — et se refermerait si l'interrupteur était retouché après être sorti du contact (nouveau front). Une plaque de pression liée à la même porte, elle, la garderait ouverte pas à pas tant que le personnage y resterait, sans qu'un second passage soit nécessaire pour la refermer.
Le seuil de poids ne discrimine encore rien tant qu'un seul acteur (le personnage) pèse sur une plaque — toute plaque s'active « prête à l'emploi ». Les blocs poussables (ci-dessous) existent désormais, mais MechanismController ne les interroge pas encore : un bloc posé sur une plaque de pression ne l'active pas — l'infrastructure de comparaison de poids est prête à l'accueillir, mais le câblage bloc → plaque reste une évolution à venir.
TileType::Key/LockedDoor sont une troisième paire déclencheur↔cible, résolue par la même liaison core::Mechanism que interrupteur/plaque↔porte ci-dessus — ajoutée au même vecteur (Level::mechanisms()), aucune notion de liaison dupliquée. MechanismController fige la nature de chaque déclencheur à la construction (comme _continuous pour interrupteur/plaque) : une clé se distingue par son TileType::Key d'origine.
Deux différences de comportement, câblées directement dans MechanismController::update :
La grille de collision est mise à jour exactement comme pour une porte classique (Solid fermée, type d'origine — Door ou LockedDoor — une fois ouverte), via le même champ _openType capturé à la construction (avant que le constructeur ne fige toutes les portes en Solid).
core::BlockController (logique pure, dans Core/Gameplay, sans dépendance rendu) fait vivre les tuiles TileType::Block (EX-GP-022) : contrairement aux mécanismes ci-dessus, dont seul l'état change, un bloc change de position. Chaque bloc occupe exactement une case, jamais à mi-chemin — pousser ou tomber le déplace d'une case entière, comme une porte bascule d'un état à l'autre sans étape intermédiaire.
collisionMap(base) complète une grille déjà résolue par core::MechanismController (portes) avec la position courante des blocs pleins : chaque case marquée Block/BlockHalf/ BlockQuarter dans base — qui ne porte que la position de départ, jamais mise à jour — est d'abord effacée, puis chaque position courante d'un bloc plein seulement est reposée comme solide. Sans cet effacement, une case quittée par un bloc resterait perçue comme un mur, indéfiniment (un bug réellement rencontré en écrivant ce contrôleur, corrigé avant livraison).
EX-GP-005 demande des blocs poussables plus petits qu'une case pleine (TileType::BlockHalf/ BlockQuarter), pour des défis de précision (sauts millimétrés) en préparation de futurs blocs poussables plus grands qu'une case. BlockController les reconnaît au même titre que Block (même poussée, même chute, toujours case par case — EX-GP-005 ne change jamais le déplacement, seulement la boîte de collision), avec un facteur de taille associé (scales(), même index que positions()) : 1 pour Block, 0.5/0.25 pour les tailles réduites. La boîte réelle d'un bloc (boxAt(index)) est centrée dans sa case : une marge (1 - facteur) / 2 de chaque côté, laissant un espace vide symétrique tout autour.
Pourquoi une routine de collision séparée. core::sweepAabb (le balayage sur grille, Physique du personnage) raisonne en cases entières : solide ou vide, jamais « partiellement occupée ». Marquer la case d'un bloc réduit comme solide dans collisionMap bloquerait donc à tort l'espace vide qui l'entoure — exactement le défaut que EX-GP-005 cherche à éviter (un bloc ×0.5 qui occupe toute sa case au sens de la collision ne serait, à l'usage, pas différent d'un bloc plein). collisionMap laisse donc les cases des blocs réduits franches dans la grille classique ; leur collision réelle est résolue par une seconde routine, core::sweepAabbVsAabb (Core/Physics/AabbVsAabb.h, quart de cercle mis à part — une simple boîte fixe, pas une surface), composée par hmi::GameSession::update après le balayage sur grille : le déplacement réellement obtenu par la grille est retesté contre la boîte réelle de chaque bloc réduit, la restriction la plus stricte des deux l'emportant toujours (cette seconde passe ne peut que réduire davantage le déplacement, jamais l'étendre — la grille reste la référence pour les murs/blocs pleins).
Cohérence stricte entre le rendu et la collision : hmi::GameSession::refreshBlockVisuals calcule la même marge ((1 - facteur) / 2) pour positionner et mettre à l'échelle le sprite d'un bloc réduit — le sprite affiché correspond donc exactement, par construction, à la boîte réellement testée, sans risque de divergence entre deux calculs indépendants.
core::PlatformController (logique pure, dans Core/Gameplay) fait vivre les tuiles TileType::MovingPlatform : contrairement aux blocs poussables ci-dessus (position case par case), la position d'une plateforme est continue, fonction déterministe du nombre de pas fixes écoulés depuis le chargement (EX-NFR-002) — jamais d'accumulation flottante (position += vitesse * dt, qui dériverait sur une session longue).
Depuis le LOT-67 (EX-GP-054), le trajet est une route à N points : startPosition est le point de départ, MovingPlatformConfig::waypoints liste les points suivants, et mode choisit entre l'aller-retour (la route puis son inverse, comportement historique généralisé) et le circuit fermé (Loop : le dernier point rejoint le premier en ligne droite, ce segment de fermeture faisant partie du cycle et se parcourant à la même vitesse). La vitesse (speed, cases par seconde) est constante sur toute la route, et le déphasage optionnel (phase, en pas fixes — même principe que DangerBlinkConfig::phase) désynchronise plusieurs plateformes d'un même niveau.
La géométrie vit dans core::PlatformPath (Core/Gameplay/PlatformPath.h), partagé avec l'overlay d'édition : le trajet dessiné dans l'éditeur est littéralement celui que le gameplay parcourt, jamais une réimplémentation parallèle. Les longueurs cumulées sont précalculées au chargement (boxAtStep est appelée plusieurs fois par pas et par consommateur) et la distance parcourue est cumulée en double précision : convertie en float, elle perdrait le bit de poids faible au-delà d'environ 16,7 millions de pas (~77 h), ce qui décalerait visiblement la plateforme en fin de longue session. Un segment de longueur nulle (point dupliqué) est traversé sans incident, et une route vide décrit une plateforme immobile plutôt qu'un niveau invalide (EX-NFR-040).
Un fichier écrit avant le multi-points (couple endX/endY) reste lu tel quel et converti en route à un point : son comportement est inchangé (EX-LVL-008). L'éditeur, lui, réécrit toujours en waypoints.
L'ordre de résolution dans le pas est la décision structurante, documentée ici parce qu'un autre ordre produit des défauts subtils et intermittents (hmi::GameSession::update, Source/Test/Systeme/test_parcours_complet.cpp, même composition) :
Écrasement : une plateforme montante contre un plafond, avec le personnage entre les deux, est mortelle (core::Player::squished, décision de cadrage retenue) — plutôt que de mettre la plateforme en pause, ce qui casserait sa position purement fonction du numéro de pas.
⚠️ Défaut connu, non corrigé (LOT-65, consigné dans CHANGELOG.md) : la seule présence d'une configuration MovingPlatformConfig dans un niveau — même immobile (speed = 0) et géométriquement loin du personnage — casse la résolution de collision pendant qu'un autre personnage suit une pente ailleurs dans ce même niveau (constaté : une chute erronée en pleine ascension d'une pente, alors qu'aucune plateforme n'est en jeu à cet endroit). Isolé par bissection (retirer uniquement la tuile movingPlatform d'une copie de niveau suffit à faire disparaître l'échec) ; racine non creusée plus loin, décision de cadrage du LOT-65 (consigner, pas corriger en cours de refonte de contenu). En pratique : éviter de combiner une plateforme mobile et une pente dans un même fichier de niveau tant que ce défaut n'est pas corrigé.
Deux notions distinctes, à ne jamais confondre — c'est la confusion la plus facile à faire ici, et le panneau « Propriétés » de l'éditeur les sépare en deux groupes explicitement libellés pour cette raison :
| Champs JSON | Sémantique | |
|---|---|---|
| Budget (EX-GP-024) | jumpBudget, dashBudget | Total consommable sur tout le tableau, jamais rechargé ; -1 = illimité. Réinitialisé au (re)chargement du niveau. |
| Capacité (EX-GP-055, LOT-67) | airJumps, dashCharges | Nombre de sauts aériens / de dashs rechargés à chaque contact avec le sol ; absent = réglage du moteur (core::PhysicsConfig). |
Les capacités sont appliquées par hmi::GameSession::loadLevel, qui construit une core::PhysicsConfig dérivée du niveau et la pose sur le système de physique (CharacterPhysicsSystem::setConfig) avant de faire apparaître le personnage — sa recharge initiale en dépend. Le dash porte désormais un compteur de charges (core::Player::dashChargesRemaining) et non plus un booléen : un tableau peut en accorder plusieurs par saut. Valeur par défaut 1, soit le comportement historique à l'identique.
Un tableau puzzle peut vouloir limiter délibérément le nombre de sauts et/ou de dashs disponibles, pour forcer le joueur à les utiliser avec parcimonie plutôt que librement (EX-GP-024). Les compteurs eux-mêmes vivent dans le composant core::Player (jumpsRemaining/dashesRemaining, ECS : entités, composants, systèmes) — pas dans le Level : le niveau ne fait que fournir la valeur initiale du budget (jumpBudget/dashBudget), copiée dans le composant au moment où le personnage apparaît (spawn). Ensuite :
Quatre variantes étendent le danger classique (TileType::Danger, case pleine et statique) sans toucher à la règle de contact mortel elle-même — seules la géométrie ou l'activation varient. Chacune vit dans une couche différente, selon que sa mortalité est géométrique (résolue directement par Core/Levels, sans état) ou temporelle (résolue par un contrôleur de Core/Gameplay, qui possède un état à faire vivre chaque pas fixe) :
TileMap ne portant qu'un TileType par case (aucune métadonnée numérique, limite déjà actée en LOT-19 pour le poids d'une plaque de pression), les paramètres du mobile et du temporisé (DangerMoverConfig/DangerBlinkConfig) vivent dans des vecteurs annexes de Level/LevelDraft, keyés par position — même patron que Mechanism/DangerLink, pas une extension de TileMap.
core::evaluateOutcome(playerBox, level, extraDangerBoxes = {}) est une fonction pure d'observation (elle ne modifie rien, ne déclenche aucune transition) qui classe l'état courant du niveau à partir de la position du personnage, en trois issues possibles (core::LevelOutcome) :
extraDangerBoxes couvre les dangers à état (mobile/commuté/temporisé, LOT-31) : Core/Levels n'a et ne doit pas avoir de dépendance vers Core/Gameplay (c'est l'inverse qui est vrai — MechanismController/DangerController incluent déjà Level.h), donc evaluateOutcome ne peut pas interroger ces contrôleurs lui-même. L'appelant, qui les possède déjà, assemble leurs boîtes actuellement mortelles et les passe en paramètre — hmi::GameSession::collectActiveDangerBoxes en jeu (mover : DangerController::moverBox pour chaque configuration ; commuté : DangerController/MechanismController::isDangerActive par position, converti en boîte via dangerHitbox ; temporisé : DangerController::isBlinkActive par position, même conversion). Un appelant qui ignore ces variantes (ou un niveau qui n'en a aucune) laisse simplement le paramètre à sa valeur par défaut, {} — comportement inchangé.
L'ordre de classement est déterministe et volontaire : si, au même pas, le personnage se trouve à la fois sur la sortie et au contact d'un danger (situation limite mais possible géométriquement), l'échec l'emporte sur le succès — une règle simple et prévisible plutôt que dépendante de l'ordre de test interne.
Cette fonction ne fait que classer l'état ; c'est côté présentation qu'il a un effet. Et depuis le LOT-67, cet effet est volontairement minimal : une issue Won en partie ne termine plus rien — il n'y a ni tableau suivant, ni écran de fin — elle ramène au menu, faute de destination.
C'est un provisoire, et il est assumé plutôt que déguisé : la sortie reste dessinée dans les cartes et redeviendra une transition vers la carte que le graphe du LOT-09 désignera. Y laisser un écran de fin de séquence aurait été plus spectaculaire et plus faux.
Ce guide décrivait ici deux mécanismes de plus : la séquence de niveaux comme donnée de contenu et le garde-fou de couverture des mécaniques, qui vérifiait que chaque type de tuile apparaissait dans un tableau franchi par le test système. Les deux supposaient une séquence ordonnée, retirée avec la notion de niveau discret (EX-LVL-010 → EX-LVL-015, consignées « retirées » dans niveaux.md plutôt que supprimées).
Ce qui les remplace n'est pas encore écrit : le graphe de cartes du LOT-09 dira comment on passe d'une carte à l'autre, et le contrôle de cohérence du contenu du LOT-49 reprendra la question de la couverture — sur un catalogue de cartes, pas sur une file.