UPS, Quand jouer rapporte plus que cliquer!



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 !
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 !
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.
See the Pen
Captain Goosebumps – Infinite procedural terrain generation V2 by Théo Gil (@theo-gil)
on CodePen.
Voilà ce qu’on entend par une section, et comment est ce qu’on la conçoit :
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…
See the Pen
Captain Goosebumps – Procedural terrain chunk by Théo Gil (@theo-gil)
on CodePen.
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.
See the Pen
Captain Goosebumps – Procedural obstacle placement by Théo Gil (@theo-gil)
on CodePen.
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.

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!
À 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 !
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.
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.
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.
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
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…
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.
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:
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);
}
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.
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 !