loading…
  • Auteur
    Théo Gil
  • Date
    09.03.2026
  • Temps de lecture
    10 min
  • Catégories
    Article technique
    Advergaming

Captain Goosebumps

Captain Goosebumps
A technical case study

Il y a quelques mois, nous avons publié Captain Goosebump. Un petit mini-jeu web casual proposant au joueur d’attraper des orbes magiques afin de redonner au monde ses couleurs.

Testez le ici : https://goosebumps.epic.net/

On en est plutôt fier, et se dit que certaines des techniques utilisées mériteraient d’être partagées !

Alors dans cet article on va papoter génération procédurale, game juice, shaders… Si ces thématiques vous parlent, plongeons directement dans le vif du sujet !

Le stack technique en bref

On utilise ici three.js pour le rendu visuel et matter.js pour la gestion de la physique.

Fun fact: étant donné l’UI minimaliste du projet, nous avons décidé de ne pas utiliser de framework UI. Juste du bon vieux HTML et CSS à l’ancienne (avec un petit peu d’animation GSAP saupoudrées  par dessus tout ça). Ça fait l’affaire, et ça la fait bien et vite !

Génération procédurale du terrain infini

Parce que nous souhaitions notre terrain infini, on s’est vite dit qu’il fallait utiliser une forme de génération procédurale.

La génération procédurale est un domaine fascinant. Il y a une infinité de manière d’y faire appel pour générer un terrain. Ici, notre recette est conçue sur mesure après nos besoins de gameplay.

Le terrain est construit section par section, les sections sont ensuite raccordées les unes après les autres.
Au démarrage du jeu, on crée N sections. Suffisamment pour que, mises bout à bout, leur longueur soit supérieur à la largeur de l’écran (+ une ou deux sections par sécurité).

Lorsqu’on joue, que le joueur avance et que le monde défile, on détruit les sections de terrain au fur et à mesure qu’elle sortent du champ de vision de la caméra derrière le joueur.
Quand ne section est détruite, une nouvelle est crée et ajoutée à la suite de notre chaine de sections, là où elle n’est pas encore visible.

Voilà ce qu’on entend par une section, et comment est ce qu’on la conçoit :

  1. Créer N points en ligne droite horizontalement où chaque nouveau point est décalé de random(minDistance, maxDistance) par rapport au point précédent.
  2. Appliquer une rotation à chaque point de random(minAngle, maxAngle) degrés en utilisant le point précédant comme origine de la rotation.
  3. Créer une CatmullRomCurve3 sur base de ces points.
  4. Échantillonner N points le long de cette courbe. Plus on échantillonnera de points, plus élevée sera la définition. Ces points échantillonnés formerons les vertices de la face supérieure du mesh de notre section.
  5. On calcule les vertices inférieurs droit et gauche en décalant le dernier et premier (respectivement) points échantillonnés sur l’axe vertical. La valeur du décalage n’est pas super importante ici. On se décide sur un nombre suffisamment important pour empêcher que le joueur ne traverse le mesh même quand il a une vitesse élevée.

Cette méthode fonctionne bien dans notre cas parce qu’utiliser des angles et des distances est intuitif pour décrire les dénivelés des sections. Plus que, par exemple, jouer avec l’amplitude et la fréquence de perlin noise…

Placement des objets sur le terrain

Obstacles

Puisqu’on a sous la main notre CatmullRomCurve3, on peut utiliser la méthode Curve.getPointAt pour échantillonner facilement des points le long de cette courbe.

A chaque fois qu’une section est crée, on calcule un nombre potentiel d’obstacles à ajouter à la section et échantillonne autant de point sur la courbe. On parcourt ensuite ces points et, pour chacun, faisons une série de vérifications pour déterminer si l’obstacle est valide ou non.

Règle n°1: Chaque obstacle doit être à une distance supérieure à MIN_DISTANCE de ses voisins.
Règle n°2: L’angle de la pente formée par la courbe à la position de l’obstacle doit être inférieur à STEEPNESS_THRESHOLD.

La première règle empêche la formation de groupes d’obstacles trop serrés les uns aux autres. Parce que ce n’est pas joli visuellement mais surtout pour garantir en toutes circonstances que le joueur a physiquement la capacité de sauter au d’un obstacle. Sans cette règle, on ne serait pas à l’abris de 13 obstacles bout à bout.

La seconde empêche la création d’obstacles dans les endroits trop pentus. On a remarqué que ça aidait à ancrer le jeu dans une réalité plus ou moins réaliste 😄

L’angle de la pente est calculé en comparant la tangeante à OBSTACLE_POSITION et la tangeante à OBSTACLE_POSITION + VERY_SMALL_LOOKAHEAD_OFFSET

Note: Le joueur va s’écraser en percutant un obstacle sur le coté mais rebondira si il percute l’obstacle par le dessus. Cette mécanique offre plus de contrôle et une meilleure liberté de mouvement au joueur.

Orbes

On parcourt les sections de terrain générée jusqu’à trouver la première qui n’est pas encore visible dans le chaps de vision de la caméra. Ensuite, on fait de nouveau appel à getPointAt pour échantillonner un point à une position aléatoire le long de la courbe.

Cela nous donne un point sur la courbe alors qu’on aimerait que l’orbe flotte au dessus. On pourrait corriger en décalant verticalement le point mais comme on peut le constater ci dessous, on obtient de bien meilleurs résultats en projetant le point le long de la normal de la courbe.

Ensuite, pendant le jeu, si le joueur passe à coté de l’orbe sans l’attraper on attend que celle ci sorte de l’écran pour la téléporter plus loin en utilisant le même processus.
Si le joueur l’attrape, on passe à la suivante.

Le joueur

On a maintenant un terrain procédural infini avec des obstacles et des orbes dessus. Il ne nous manque plus que le principal, le joueur !

Du point de vue du moteur physique, le joueur est représenté par un simple cercle. A chaque frame, on copy la position et rotation du collider (la partie physique) et on applique les deux au mesh (la partie visuelle). On synchronise la visualisation avec la simulation physique.

Note: il est important de désactiver la vélocité angulaire de cette entité physique sinon elle roulerait sur elle même comme une boule de neige!

Accélération automatique

À chaque frame, on écrase la vélocité X du joueur par la constante X_VELOCITY.

Le fait que l’accélération / vitesse soit géré automatiquement permet au joueur de n’avoir à se soucier que de la partie saut.

C’était primordial pour nous d’avoir le système de contrôle le plus simple possible de sorte à avoir la barrière d’entrée la plus basse possible. Pas besoin de tutoriel, dés que la page se charge on sait instantanément comment jouer au jeu !

Les prochaines astuces sont très connus des games designers puisqu’on peut les trouver dans presque (si ce n’est tous) les jeux de plateforme de 20 dernières années. Elles vont s’additionner et offrir un système de contrôle plus naturel, tolérant, réactif… bref, un système plus agréable !

Saut à hauteur variable

Wahoo! Plus longtemps on reste appuyé sur le bouton de saut, plus on saute haut. Facile ! Cette mécanique augmente drastiquement le contrôle du joueur. Voici comment nous avons choisi de l’implémenter ici :

Quand le joueur initie le saut, on démarre un timer JUMP_BUTTON_DOWN.
A chaque frame suivante, jusqu’à ce que le joueur touche de nouveau le sol ou relâche le bouton de saut, on ajoute la valeur de delta time (le temps en ms écoulé entre cette frame et la précédente) au timer.
Tant que le bouton de saut est maintenu et que la valeur du timer est inférieur à JUMP_TIMER_MAX, on écrase la vélocité Y du joueur par la constante Y_VELOCITY_JUMP.

Coyote jump

Cette mécanique tire son nom d’un célèbre coyote de cartoon qu’on peut voir à l’arrêt en plein air pendant un court instant, avant qu’il ne se rende compte que plus rien ne supporte ses pieds et que la physique reprenne son cours, faisant tomber notre pauvre coyote…

L’idée est simple : si le joueur appuie sur le bouton de saut quelques frames après avoir physiquement quitté le sol, on autorise le saut quand même.

Nous autre humains n’avons pas des réflexes instantanés. Si le système de jeu sait en 16ms que le joueur touche le sol ou non, le temps de réflexe moyen est aux alentours de 250ms chez nous. Donc même si le joueur n’est plus strictement mécaniquement au sol, on met en place une courte fenêtre de tolérance pendant laquelle le saut pourra encore avoir lieu.
Peu de choses sont plus frustrantes que de voir son personnage tomber alors que « je jure que j’ai appuyé sur le bouton alors qu’il était encore au bord », je sais que vous savez…
C’est d’autant plus crucial que les composant physiques (utilisé pour calculer si oui ou non le joueur touche le sol), même si proches, ne sont pas exactement égaux à leur contreparties visuelles.

On peut l’implémenter facilement avec un nouveau timer. COYOTE_TIMER est démarré dés que le joueur quitte le sol. On y ajoute le delta time à chaque frame jusqu’à ce que le joueur appuie sur bouton de saut ou touche de nouveau le sol. Lorsqu’on appuie sur bouton saut, si le joueur touche le sol OU sil la valeur de notre timer est inférieure à COYOTE_TIMER_MAX, on active le saut.

La seuil de tolérance, défini par COYOTE_TIMER_MAX, va être différent d’un jeu à l’autre. On va essayer de viser la valeur la plus basse possible, juste assez pour que la mécanique soit là mais pas suffisamment pour que le joueur ne s’en rende compte. Si l’effet est trop évident il risque d’être perçu comme un bug.

Le "coyote jump" autorise le joueur à sauter même après avoir quitté le sol

Jump buffering

Mes excuses pour les anglicismes mais je préfère vous épargner « Mise en mémoire tampon du saut » 😁

L’idée ici est la même que pour le coyote jump (donner plus de tolérance), mais dans l’autre sens. Cette fois, si le joueur appuie sur le bouton de saut quelques frames avant de toucher le sol, on va garder l’action en mémoire et faire sauter le joueur dés qu’il touche le sol pour de bon.

Fonctionnellement c’est très similaire, on va encore se baser sur un timer. A l’appuie sur le bouton de saut, si le joueur ne touche pas le sol, on démarre le JUMP_BUFFERING_TIMER. A chaque frame, on. ajoute delta time. Quand le joueur touche de nouveau le sol, si si la valeur du timer est inférieur à JUMP_BUFFERING_TIMER_MAX, le saut est déclenché automatiquement.

Encore une fois, la clé c’est la subtilité. Un tout petit peu de tolérance va largement améliorer la sensation de contrôle mais on va faire attention à ne pas trop en faire au risque de créer l’effet inverse.

Le "jump buffering" autorise le joueur à sauter quelques frames avant d'avoir touché le sol

Il y a déjà beaucoup d’excellentes ressources qui expliquent ces sujets là en détails. Je recommande vraiment de jouer avec cette démo interactive pour ressentir comment ces différents paramètres modifient l’expérience de jeu : https://gmtk.itch.io/platformer-toolkit

Gestion de la caméra

Positionnement

La solution évidente est de copier coller la position du joueur à la caméra mais sans plus d’effort ça ne fonctionne pas très bien avec notre gameplay.

Quand le joueur saute très haut, suivi par la caméra, on fini par ne plus voir le sol. Ce qui ne marche pas puisqu’on a besoin de voir le sol pour anticiper notre atterrissage.

A là place, nous avons décidé de raycaster un rayon vers le bas depuis la position du joueur. En d’autres termes, calculer la position sur le terrain pile en dessous du joueur et d’utiliser cette position comme cible pour la caméra. De cette manière le sol est toujours visible.

C’est bien mais on a maintenant le problème inverse de celui qu’on avait initialement. Quand le joueur saute trop haut, c’est maintenant lui qui sort du champs de vision. Ca ne marchera pas non plus…

Zoom

On peut corriger ça en calculant les dimensions du frustum (cone représentant le champ de vision de la caméra) et en modulant la position Z de la caméra de sorte à ce que et l’intersection du raycast sur le terrain et le joueur soit toujours comprises dans le frustum.

Décallage

Pour finir, on décale légèrement la position de la sur l’axe XY d’une distance arbitraire, de sorte à voir un peu plus à droite (devant le joueur) et en haut (pas la peine de voir trop ce qui est en dessous du joueur, ce n’est pas le plus important pour le gameplay).

En combinant ces trois points on a:

  1. toujours le joueur de visible à l’écran, on sait où on est
  2. toujours le sol visible à l’écran, on sait où on va
  3. la vision sur les orbes et obstacles bien dégagée pour les anticiper

Masque de couleurs dynamique et effet de trainée

Masque de couleurs dynamique

Cet effet repose sur une technique particulière de mapping UV (décrite en détails par Vicente Moscardo Ribes dans cet article).

Voici à quoi ressemblent les UVs dans notre cas :

Funky ? Peut être. Efficace ? Sans aucun doute !

Le jeu va démarrer sans aucune couleur et celles ci seront introduites progressivemment au fur et à mesure que le joueur va ramasser des orbes.

Pour ce faire on va utiliser 6 nouvelles textures de masque, chacune liée à une orbe et masquant certaines partie de notre color map. Ces 6 masques sont ensuite combinées dans les composants rouges, verts et bleus de deux fichiers.

Avec un petit shader custom, on est ensuite capable d’activer et désactiver certaines couleurs en combinant les différents masques.

varying vec2 vUv;

uniform sampler2D uMap; 
uniform sampler2D uColorMaskRGB;
uniform sampler2D uColorMaskPWY;
// They all are initialized at 0.0 and are animated to 1.0 when the player collects the corresponding orb 
// Tip: If you are an optimisation freak, you can pack those 6 float uniforms into only two vec3 uniforms
uniform float uRedsAmount;
uniform float uGreensAmount;
uniform float uBluesAmount;
uniform float uPurplesAmount;
uniform float uWhitesAmount;
uniform float uYellowsAmount;

void main() {
  // Sample base color, this is what the color of the pixel would be without any processing 
  vec4 baseColor = texture2D(uMap, vUv);

  // Compute the grayscale value of the pixel by averaging its red, green and blue components
  float grayscale = (baseColor.r + baseColor.g + baseColor.b) / 3.0;
  vec4 grayscaleColor = vec4(
    grayscale,
    grayscale,
    grayscale,
    baseColor.a
  );

  vec4 colorMaskRGB = texture2D(uColorMaskRGB, vUv);
  vec4 colorMaskPWY = texture2D(uColorMaskPWY, vUv);

  // Compute the mask value
  float mask = 0.;
  mask += colorMaskRGB.r * uRedsAmount; // Red orb mask is stored in the red channel
  mask += colorMaskRGB.g * uGreensAmount; // Green orb mask is stored in the green channel
  mask += colorMaskRGB.b * uBluesAmount; // Blue orb mask is stored in the red blue
  mask += colorMaskPWY.r * uPurplesAmount; // Purple orb mask is stored in the red channel
  mask += colorMaskPWY.g * uWhitesAmount; // White orb mask is stored in the green channel
  mask += colorMaskPWY.b * uYellowsAmount; // Yellow orb mask is stored in the blue channel
  mask = clamp(mask, 0.0, 1.0); // Clamp between 0 and 1 for good measure

  // Mix both baseColor and grayscaleColor depending on the mask value.
  // If mask is 1.0, then we'll use baseColor, if it is 0.0, we'll use grayscaleColor
  vec4 finalColor = mix(grayscaleColor, baseColor, mask);
}

Trainée

Pour la trainée, on a réutilisé la technique déjà développée et décrite par Karim dans cet article (voir la section « En parlant de dessiner des cercles »).

La texture noir et blanc de la trainée est ensuite envoyée à notre shader custom et utilisée comme masque pour incruster un calque « bleu nuit » sur le rendu final.

See the Pen
Reveal mask
by Karim Maaloul (@Yakudoo)
on CodePen.

Conclusion

Cet article est un bonne illustration de comment combiner plusieurs systèmes basiques pour résulter en une expérience riche et complexe.

Certains aspects du jeu n’ont pas été couvert dans cet article (audio, système de particules, object pooling, animations…). Peut être aurons nous la chance de les explorer dans un prochain !


UPS, Quand jouer rapporte plus que cliquer!

UPS delivery Day
  • Advergaming
  • Publicité
LIRE L’ARTICLE

EAA, Accessibilité & normes WCAG

Accessibility cover, a blind man using a browser to buy products
  • Article technique
LIRE L’ARTICLE

Headless & Wordpress

Comprendre notre infrastructure
  • Article technique
LIRE L’ARTICLE

Réalisations