|
JustAnotherDnDGame 0.1.0
Jeu de rôle tactique au d20, vue de dessus, en C++/Qt
|
Toute la physique vit dans core::CharacterPhysicsSystem (système ECS pur, ECS : entités, composants, systèmes) et s'appuie sur la primitive de collision core::sweepAabb. Elle s'exécute au pas de temps fixe (Boucle de jeu et pas de temps fixe), donc déterministe : mêmes entrées → même trajectoire, toujours (EX-NFR-002).
Repère du projet (détaillé en Mathématiques du moteur) : origine haut-gauche, x vers la droite, y vers le bas. Une tuile = 1 unité monde. « Monter » est donc une vitesse négative en y ; la gravité est une accélération positive en y.
Un objet qui se déplace vite peut, en un seul pas de simulation, traverser entièrement un obstacle fin sans jamais être détecté « dedans ». C'est le phénomène de tunneling. Exemple chiffré : un mur a une épaisseur de 1 unité. Le personnage se déplace à 20 unités/seconde et le pas fixe dure 1/60 s (Boucle de jeu et pas de temps fixe) : il avance donc de 20 × 1/60 ≈ 0,33 unité par pas — moins que l'épaisseur du mur, donc ce cas précis serait détecté. Mais à 80 unités/seconde (un dash rapide, par exemple), l'avancée par pas est de 80 × 1/60 ≈ 1,33 unité : plus que l'épaisseur du mur. Une approche naïve qui ne testerait le recouvrement qu'après avoir déplacé la boîte à sa position finale ne verrait jamais l'objet « dans » le mur : à la fin du pas, il serait déjà passé de l'autre côté. Le mur, pourtant infranchissable en théorie, laisserait tout traverser en pratique.
Le balayage continu (swept collision) évite ce piège en testant tout le trajet parcouru pendant le pas, pas seulement le point de départ et le point d'arrivée. core::sweepAabb(box, delta, tiles) déplace une boîte core::Aabb de delta en garantissant qu'elle ne traverse jamais une tuile solide, quelle que soit la vitesse (EX-GP-014) — c'est le cœur de la robustesse « pas de tunneling » du moteur.
On résout le déplacement X puis Y, séparément (fonctions internes sweepX puis sweepY, dans SweptCollision.cpp). Pour chaque axe, dans l'ordre :
La passe Y part de la position X déjà résolue par la passe précédente : c'est ce qui produit naturellement le glissement le long d'un mur — un personnage qui avance en diagonale contre un mur vertical voit sa composante X bloquée mais continue de tomber en Y, sans effet de « collage » artificiel à coder séparément.
Une alternative plus « naturelle » consisterait à calculer le paramètre t ∈ [0, 1] du point exact de contact le long du trajet, puis à poser position = position + delta * t. Ce moteur ne fait pas cela : il cale directement position sur la coordonnée entière du mur touché. La raison est la dérive flottante (Mathématiques du moteur) : position + delta * t est un calcul flottant qui peut laisser un résidu minuscule (par exemple 4.99999998 au lieu de 5.0 exactement), suffisant pour que le test de collision du pas suivant considère encore la boîte comme légèrement à l'intérieur du mur — un bug de « bord interne » où le personnage semble coller au mur plutôt que d'y glisser librement. Caler sur la coordonnée entière du mur donne une position exacte, sans résidu, et élimine cette classe de bug par construction plutôt que par correction a posteriori.
Note historique (voir le commit de la TACHE-02) : une première version « diagonale », résolvant X et Y simultanément par somme de Minkowski ⧉ et méthode des slabs ⧉, a été abandonnée — élégante sur le papier, mais victime du même bug de bord interne à cause de la dérive flottante. Le balayage par axe s'est révélé plus robuste, tout en restant tout aussi continu (aucune réintroduction du tunneling). Une fine « peau » de tolérance (kSkin) sur l'axe perpendiculaire au mouvement évite de confondre deux situations proches : marcher sur un sol (contact attendu, pas un blocage) et buter contre lui (blocage réel).
SweepResult porte la position finale de la boîte après résolution, et une normale indicatrice par axe (normal.x, normal.y), chacune valant -1, +1 ou 0 — pas un vecteur unitaire au sens géométrique habituel, mais un indicateur de quel axe a été bloqué et dans quel sens (signe « surface → boîte » : un sol sous la boîte, avec y vers le bas, donne normal.y < 0). Le système de physique lit cet indicateur pour deux choses : annuler la composante de vitesse correspondante (on ne peut pas continuer d'accélérer contre un mur) et déduire l'appui — un contact bloquant sous la boîte signifie qu'elle repose au sol (grounded).
Contrairement à un bloc plein, une tuile de pente ou d'arrondi (core::TileType::SlopeUpRight / SlopeUpLeft / RoundedUpRight / RoundedUpLeft) renvoie toujours false pour core::isSolid — y compris pour le balayage classique du §1. Une telle tuile couvre une case dont la hauteur varie entre 0 (bord haut) et 1 (bord bas) selon la position horizontale ; si elle était solide au sens classique, le balayage par axe la traiterait comme un bloc plein occupant toute la case, et son bord haut agirait comme un mur invisible bloquant le personnage bien avant qu'il n'atteigne la surface elle-même. Rendre la tuile non solide et lui substituer un suivi de surface dédié (ci-dessous) évite ce piège par construction.
Core/Physics/SlopeGeometry.h/.cpp définit la géométrie d'une pente à 45° (montée pleine sur toute la largeur d'une case) et, symétriquement, celle d'un arrondi en quart de cercle de même rayon (une case) :
localX ∈ [0, 1[ est la position horizontale dans la case (0 = bord gauche) ; le résultat, dans [0, 1], se lit depuis le haut de la case (0 = haut, 1 = bas) — il faut l'additionner à la ligne (row) de la case pour obtenir la hauteur monde de la surface (EX-ARCH-020). L'arrondi (LOT-23, EX-GP-004) réutilise intégralement l'infrastructure de la pente (LOT-22, EX-GP-003) : un nouveau case dans slopeSurfaceHeight et dans isFollowableSurface suffit — ni resolveSlopeFollow (ci-dessous) ni la correction du balayage horizontal (§ suivante) n'ont besoin de connaître la nature (linéaire ou courbe) de la surface suivie, seulement qu'elle existe (isFollowableSurface). C'est délibéré (voir la décision de cadrage de LOT-23 dans son épic) : si l'ajout d'une nouvelle forme de surface avait exigé de retoucher la passe de résolution elle-même, ç'aurait été le signe que l'abstraction posée par LOT-22 était mal choisie.
core::resolveSlopeFollow est appelé après le balayage classique (§1), à chaque pas : il compare le bord bas de la boîte avant ce pas et après, et parcourt toutes les lignes traversées entre les deux (pas seulement la position finale) — le même principe anti-tunneling que le balayage lui-même, indispensable car une chute rapide pourrait sinon « sauter » une pente entière en un seul pas, la grille classique ne la voyant jamais comme solide. Si le bord bas se trouve à ou sous la surface suivable à sa colonne courante (newBottomY >= surfaceY - tolérance), la position verticale est calée exactement sur cette surface et la vitesse verticale annulée. Deux exceptions protègent les autres mécaniques :
Suivre une pente signifie que le bord bas de la boîte peut se retrouver à l'intérieur d'une case (par exemple à mi-hauteur), et non plus seulement effleurer sa frontière comme sur un sol plat. Ce nouvel invariant a révélé un piège pendant les tests d'intégration de la TACHE-02 : une pente immédiatement suivie d'un bloc plein de même hauteur (le cas normal d'un raccord pente → palier) bloquait le personnage à mi-montée. La cause : le balayage horizontal (sweepX, SweptCollision.cpp) considère bloquante toute case solide chevauchée verticalement par la boîte ; or, à mi-pente, la boîte chevauche déjà la ligne de la pente — la même ligne que le bloc plein voisin — et s'y retrouve donc bloquée avant même d'avoir fini de la gravir. La fine « peau » kSkin qui règle ce problème pour un sol plat (§1) ne suffit pas ici : elle n'exclut que la ligne exactement effleurée à la frontière, alors qu'une pente fait chevaucher la boîte franchement à l'intérieur de sa case.
La correction (rowIsSlopeGround dans SweptCollision.cpp) généralise le principe de kSkin : une ligne où l'empreinte horizontale courante de la boîte repose sur une pente n'est jamais considérée comme un mur par sweepX, même si un bloc plein voisin partage cette ligne. La résolution verticale (§1, sweepY) reste inchangée et corrige naturellement, une fois la case voisine atteinte, le léger reliquat d'enfoncement — un personnage nettement plus étroit qu'une case (taille réelle 0,4 × 0,8, Niveaux : modèle, chargement, mécanismes, budgets) ne produit alors qu'un rattrapage borné et non bloquant au raccord, pas un à-coup perceptible ni un blocage (validé par les tests d'intégration SuitUnePenteAscendanteEnMarchant, TransitionPenteSolPlatSansAACoup). Cette correction ne cible pas spécifiquement la pente : elle interroge core::isFollowableSurface, donc s'applique sans modification à l'arrondi (LOT-23) et à toute future surface suivable.
Contrairement à la pente (pente constante, ±1 sur toute la case), la dérivée de la formule du quart de cercle tend vers l'infini à l'extrémité où la hauteur approche 1 (tangente verticale) — géométriquement inévitable pour un quart de cercle raccordant une tangente horizontale à une tangente verticale. Cela n'affecte ni la justesse de slopeSurfaceHeight (la formule reste exacte à tout localX, y compris tout près de cette extrémité) ni le suivi (resolveSlopeFollow cale la position sur la valeur exacte de la formule, quelle que soit sa pente locale) ; c'est une caractéristique du ressenti à connaître pour le level design (§4, game feel) : un personnage qui approche cette extrémité en marchant y ressent une accélération verticale perceptible, à la différence d'une pente linéaire (progression strictement uniforme).
À chaque pas, la vitesse verticale est d'abord mise à jour sous l'effet de la gravité, puis cette vitesse sert à calculer le déplacement, qui est enfin résolu par balayage. Ce schéma — vitesse d'abord, position ensuite, à partir de la vitesse déjà mise à jour — s'appelle l'intégration d'Euler explicite ⧉ (la méthode numérique la plus simple pour approximer l'évolution d'un système physique continu par des pas discrets) :
C'est un choix délibéré de simplicité : des méthodes d'intégration plus précises existent (Euler semi-implicite, Verlet, Runge-Kutta), mais à pas fixe et petit (1/60 s), Euler explicite est largement suffisant pour un plateformer, et surtout le plus simple à raisonner et à déboguer.
La gravité n'est cependant pas une simple constante : elle est effective (EX-GP-018), c'est- à-dire modulée selon la phase du saut (les multiplicateurs vivent dans core::PhysicsConfig) — une pratique courante en game design pour obtenir une trajectoire qui « a l'air bien » plutôt qu'une parabole physiquement pure et perçue comme molle :
Sans borne, une chute prolongée accélérerait indéfiniment sous gravité constante — injouable (le personnage traverserait plusieurs tuiles par pas, au risque de tunneling malgré le balayage continu, et le ressenti deviendrait imprévisible). Plutôt qu'un plafond arbitraire (std::min), la chute intègre une traînée (résistance de l'air, proportionnelle à la vitesse) qui s'oppose au poids (masse core::Player::mass × gravité effective) — la même idée que la chute d'un parachutiste : au début, peu de vitesse, donc peu de traînée, donc la gravité l'emporte largement et la chute accélère franchement ; puis, la vitesse augmentant, la traînée grandit avec elle jusqu'à égaler le poids — l'accélération nette tombe alors à zéro, et la vitesse cesse de croître (la vitesse terminale) :
La vitesse terminale émerge de l'équation à l'équilibre (netAcceleration = 0) : vitesseTerminale = (masse × effectiveGravity) / fallDragCoefficient. Le coefficient par défaut (fallDragCoefficient = 3.6) est calibré pour retomber sur l'ancienne borne fixe (25 unités/s) à masse par défaut (1,0) — la valeur finale ne change pas, seule la courbe pour y parvenir devient progressive plutôt que coupée net. Une masse plus grande fait pencher la balance vers le poids (traînée relativement plus faible) : elle tombe donc plus vite, à traînée égale — cohérent avec l'intuition (un objet lourd est moins freiné par l'air, à forme égale, que sa masse ne le fait tomber).
Le terme game feel désigne l'ensemble des petits ajustements, souvent invisibles et parfois « physiquement faux », qui rendent un contrôle agréable et réactif plutôt que strictement réaliste. Le saut de ce moteur en est l'exemple central : il n'est jamais un simple « si au sol et bouton pressé, alors vitesse verticale = -jumpSpeed » — plusieurs tolérances, chacune corrigeant un défaut de perception précis, s'y ajoutent.
Le déclenchement du saut est contextuel : plusieurs sources d'autorisation sont testées, dans cet ordre de priorité :
Deux tolérances supplémentaires, indépendantes de la source d'autorisation, complètent le ressenti :
Le budget de sauts du tableau (EX-GP-024, Niveaux : modèle, chargement, mécanismes, budgets) s'ajoute par-dessus toutes ces règles : jumpsRemaining peut refuser un saut par ailleurs autorisé, une fois épuisé (-1 signifie illimité — aucune limite de budget, indépendamment des règles de game feel ci-dessus).
EX-GP-017. Un dash est une ruée brève, à vitesse constante et élevée, dans une direction choisie par le joueur. Sur le front d'appui du bouton dédié (voir Entrées et actions logiques), si le dash est disponible (dashAvailable, rechargé uniquement au contact du sol) et que le budget n'est pas épuisé (dashesRemaining), le personnage part dans la direction (moveX, moveY) de l'intention de déplacement, normalisée (Mathématiques du moteur — sans cette étape, une diagonale irait √2 fois plus vite qu'un dash cardinal). Cette direction couvre 8 orientations possibles (les 4 cardinales et les 4 diagonales) ; à défaut de direction pressée, le dash part dans l'orientation courante du personnage (facing). Pendant sa durée (dashDuration), à vitesse constante dashSpeed :
EX-GP-016. Après résolution du balayage du pas (§1), le système détecte un contact horizontal : le personnage est en l'air et son mouvement voulu le pousse contre un mur vertical. Ce contact fixe wallDirection (le sens du mur touché : -1 à gauche, +1 à droite). Deux comportements en découlent :
Pour chaque personnage, CharacterPhysicsSystem::update exécute, dans cet ordre précis, à chaque pas fixe :
Cet ordre précis est un contrat à respecter scrupuleusement dans toute évolution du système : c'est lui qui garantit à la fois le déterminisme (EX-NFR-002, Boucle de jeu et pas de temps fixe) et la compatibilité entre mécaniques qui, sans lui, pourraient s'annuler ou se combiner de façon incohérente d'un pas à l'autre (par exemple, appliquer la gravité avant de traiter un dash en cours romprait la suspension de gravité du §4).