Click Hanger - Génération procédurale d'une montagne
Aujourd'hui, nous sommes fiers de vous présenter notre travail sur le dernier jeu de la Française Des Jeux (le loto national français) ! Réalisé avec Isobar et nos amis de James Bang. ▶ Regardez la…
AL
Aymeric Lamboley
27 juin 2018
27 juin 2018 9 min de lecture
Aujourd’hui, nous sommes fiers de vous montrer notre travail sur le dernier jeu de la Française Des Jeux (le loto national français) ! Réalisé avec Isobar et nos amis de James Bang.
Click Hanger, le premier jeu non-loterie de la FDJ, est un jeu d’escalade mobile disponible sur Android et iOS.
Découvrez la suite sur son processus de développement ci-dessous !
Génération
Graines (seeds) : Pour générer une montagne différente à chaque partie tout en pouvant rejouer la même si nécessaire, nous avons mis en place un système de graines (une graine est une chaîne de caractères utilisée par différents générateurs aléatoires pour produire des valeurs variées mais prévisibles ; donner la même graine à un générateur aléatoire donnera le même résultat « aléatoire »).
Nous donnons d’abord une graine principale au générateur (saisie à la main ou valeur aléatoire), à partir de laquelle nous générons une liste de graines secondaires, une par type de valeur générée. Nous utilisons deux types de générateurs pour obtenir des valeurs aléatoires :
Le premier est le System.Random, nous en instancions une liste avec pour chacun une graine spécifique donnée, l’objet System.Random nous donnera un nouveau double aléatoire entre 0 et 1 à chaque appel de ‘NextDouble()’
public static float nextRandom(randomIndex randomIndex) { return (float)Instance.randoms[(int)randomIndex].NextDouble(); }
Le second est le bruit Open Simplex, celui-ci est plus intéressant car à partir d’une graine il génère un champ de valeurs multidimensionnel (nous avons utilisé un champ 2D). Les valeurs données par le bruit Open Simplex sont comprises entre -1 et 1.
La conséquence est que demander 2 valeurs « proches » dans le champ donnera 2 résultats différents mais similaires. Ex: (x=2, y=2) => 0.54 (x=1.9, y=2) => 0.49 (x=1.8, y=1.9) => 0.47
Ces « bruits » sont généralement utilisés pour représenter des cartes d’élévation aléatoires, car leurs valeurs continues mais aléatoires facilitent la génération de terrains modérément accidentés.
Valeur de bruit du noir (valeur minimale) au blanc (valeur maximale)
le bruit utilisé comme élévation de carte
Montagne : Maintenant que nous avons un moyen efficace d’obtenir des valeurs aléatoires mais intéressantes, nous pouvons travailler à la construction de la montagne. Pour cela, nous avons décidé de travailler bloc par bloc : le designer réalise une série de blocs disposés de manière à s’ajouter les uns aux autres et à former un mur, qui sera le flanc de montagne visible.
Génération, la seule partie visible en jeu est le côté droit de la montagne, donnant l’impression que l’on se trouve sur le bord de la montagne.
La génération suit ce schéma :
Nous générons d’abord un objet « montagne », qui contient une liste de « paliers » (rangées horizontales de blocs), la montagne génère les 15 premiers paliers puis en génère d’autres au fur et à mesure que l’on grimpe.
on voit ici les différents paliers et la montagne changer de direction en suivant les valeurs de bruit
la montagne telle que vue en jeu (le héros reste sur le côté droit de la montagne pour laisser voir l’arrière-plan)
Chacun de ces paliers contient un bloc sur lequel le joueur pourra grimper. Chaque palier apparaît derrière le précédent, un peu plus haut, et peut être décalé plus à gauche ou plus à droite ; cela donne l’impression de grimper de palier en palier, et à mesure que l’on avance, la montagne ressemble davantage à une pyramide qu’à un simple mur. La hauteur et la position de chaque palier sont contrôlées par un Open Simplex Noise principal pour obtenir des valeurs cohérentes, puis par un tirage purement aléatoire pour ajouter un peu de variété ; chaque bloc est également affecté individuellement par de l’openSimplexNoise et de l’aléatoire, en utilisant sa position dans le palier comme référence.
En plus des positions des paliers, la génération utilise également différents blocs, petits props et biomes : -Chaque montagne a un ou plusieurs biomes. -Chaque biome a de multiples blocs et éléments de décor (végétation, petits rochers etc …).
Au début de la génération, la montagne utilise les blocs du Biome 1 et place de la végétation et d’autres éléments aléatoires de ce biome sur les blocs. En grimpant, on peut éventuellement voir un changement de biome (de moins en moins de blocs du Biome 1 apparaissent tandis que de plus en plus de blocs du Biome 2 apparaissent). Le level designer peut indiquer par exemple que « le Biome 1 commence à 0% de la montagne et se termine à 10% tandis que le second commence à 15% et se termine à 25% ». Cela aurait pour résultat que la montagne comporte des blocs et éléments du Biome 1 sur ses 10 premiers pourcents, un mélange d’éléments des biomes 1&2 entre 10% et 15%, puis uniquement des éléments du biome 2.
Chaque montagne commence par un prefab de sol
Toutes les montagnes n’ont pas des blocs carrés, le programme permet des orientations et des blocs spéciaux pour sa génération
on voit ici en jeu une transition en cours entre des blocs herbeux et des blocs sans végétation
Outil d’édition de niveaux
Les données de niveau qui définissent les checkpoints de la montagne ainsi que toutes les variables nécessaires pour définir la difficulté et les chances d’apparition des symboles et bonus étaient éditées via une interface en ligne, pour itérer rapidement et partager facilement avec notre client.
Il a été créé avec Unity afin que les classes de données de niveau soient partagées avec le jeu, tout comme les AnimationCurve utilisées pour évaluer la progression des variables en fonction de l’altitude.
En réalité, l’éditeur est une simple interface utilisant Vectrosity pour éditer les valeurs des AnimationCurve, et exporte le tout en json. Dans le processus d’itération, les Game et Level designers travaillant sur les montagnes pouvaient modifier visuellement les données de la montagne puis simplement recharger une scène pour voir les changements.
Le format de fichier choisi est simplement le .json, et comme les classes sont partagées entre l’éditeur et le jeu, l’export et l’import sont relativement faciles. Il a juste fallu utiliser les callbacks de sérialisation/désérialisation de JSON.NET pour gérer la sérialisation et la désérialisation des courbes d’animation, ce qui permettait une édition de courbes allant du simple au complexe. Ce sont en fait des courbes de difficulté pour l’ensemble de la montagne.
Une montagne est construite à partir de plusieurs « modules », des sections de montagne répétables, définissant la fréquence des checkpoints, les chances d’apparition de la monnaie secondaire ou des bonus, ainsi que des courbes de difficulté individuelles pour le tirage des symboles.
L’éditeur pouvait être exécuté dans Unity, sur le web, ou en tant que fichier standalone communiquant simplement avec un fichier php gérant la synchronisation du fichier de données de niveau. Au lancement, à moins que la version en ligne ne soit plus récente, l’application démarre avec sa propre version en cache des fichiers de données de niveau.
Reconnaissance de gestes Le système de reconnaissance de gestes est basé sur l’algorithme $P Point-Cloud. Il y a quelques années, nous en avions fait une version Unity, c’est celle-ci qui est utilisée dans ce jeu !
Personnalisation des personnages Dans le jeu, vous pouvez choisir votre genre et votre apparence. Des personnages sont disponibles à l’achat et vous pouvez échanger têtes, corps ou jambes pour créer la combinaison de votre choix, si un set complet ne vous convient pas.
Pour y parvenir, nous avons essayé beaucoup de choses avec l’animator, malheureusement découper puis rassembler des skinned meshes individuels appartenant au même fbx s’est avéré difficile. Nous avons en fait essayé de le faire en code, mais dès qu’un skinned mesh - la tête, par exemple - d’un personnage distinct remplaçait la tête initiale, même si toutes les propriétés comme l’os (Transform) auquel il devait être rattaché étaient correctes, il ne s’animait plus.
Cela restait toutefois possible à la main dans l’éditeur, mais pas au runtime via le code.
C’était comme si Unity effectuait un traitement en arrière-plan ; malheureusement, nous n’avons pas réussi à comprendre ce qu’il faisait (avec le recul, il est possible qu’il traitait les instances de mesh et réinitialisait certaines données de vertex). La solution simple que nous avons retenue a donc été que l’animator exporte des objets riggés et animés contenant toutes les variantes possibles des parties des personnages. N’ayant que 6 sets complets par genre, il était assez simple d’activer/désactiver des objets enfants et de combiner plusieurs personnages en un seul… et de déclencher une explosion de particules :)
Certains plugins de l’Asset Store semblent capables de fusionner des skinned meshes sans perdre l’animation, malheureusement il était trop risqué pour nous de prendre le temps de tester ces solutions. Cela aurait probablement donné une solution plus légère, puisqu’il n’aurait pas fallu charger tous les meshes possibles en même temps.
Achats intégrés Unity Unity a lancé les IAP sans code, un excellent moyen de gérer les achats intégrés dans votre jeu sans avoir à écrire beaucoup de code. La fonctionnalité phare est de pouvoir les définir dans Unity puis de les exporter directement vers les stores Apple et Google. Fini le bête copier/coller ! C’est vraiment sympa de voir Unity proposer ce genre de plugin.
Firebase Le backend de cette application repose sur Firebase. Firebase, créé par Google, est devenu incontournable pour les applications mobiles aujourd’hui. Il inclut : analytics, authentification utilisateur (par email, Facebook, Twitter, Google…), une puissante base de données NoSQL, du stockage, des notifications push, de la configuration à distance, du crash reporting… bref, un indispensable !
La majorité des données du jeu sont stockées dans la base Firebase : objets avec leur prix et la puissance de leurs effets, informations d’avatar, niveaux de montagnes… Cette structure a beaucoup évolué pendant le développement du jeu, donc avoir une base NoSQL nous a permis d’être vraiment agiles, et la lire comme un simple json était facile (une base Firebase n’est finalement qu’un json). Au lancement de l’application, on vérifie simplement si le numéro de version a évolué, puis on parse les données et on les sauvegarde dans un PlayerPrefs Unity. Le fait de les avoir sous forme de structure typée nous a fait gagner beaucoup de temps lors des requêtes grâce à Linq. Cette partie des données de jeu est donc en lecture seule, et nous avions aussi des données sauvegardées par utilisateur. Voici les permissions de la base : tout peut être lu par n’importe qui, mais le nœud “users” ne peut être écrit que si le nœud uid correspond à l’utilisateur authentifié, plutôt simple non ?
Le jeu utilise iCloud et Google Play Saved Games pour sauvegarder les données utilisateur sur le même OS. Nous aurions pu utiliser la base Firebase pour faire la même chose (avec l’avantage de sauvegarder la progression entre OS) via une connexion Facebook, mais les clients ne voulaient pas imposer une connexion Facebook, donc nous avons utilisé les services natifs des OS. Dans le jeu, nous avons des tournois hebdomadaires sur des maps spéciales avec un système de tickets (on peut jouer 3 fois maximum en 18 heures) et des classements personnalisés mettant en avant les résultats de vos amis. Ces scores sont sauvegardés dans Firebase, et voici la logique serveur Firebase (eh oui, on a des Cloud functions !!) pour distribuer les tickets :
exports.tickets = functions.database.ref('/users/{userId}') .onWrite(event => { const maxTickets = 3; const promises = []; // Exit when the data is deleted. if (!event.data.exists()) return; // On user creation add tickets and prepare time_counter if (!event.data.previous.exists()) { promises.push(event.data.ref.child('tickets').set(maxTickets)); promises.push(event.data.ref.child('time_counter').set(0)); return Promise.all(promises); } const previous = event.data.previous.val(); const original = event.data.val(); const hour = 3600 * 1000; // sec * ms if (original.time == previous.time || previous.tickets == maxTickets || previous.time_counter == undefined || previous.tickets == undefined) return; let time_counter = previous.time_counter + original.time - previous.time; if (time_counter > 18 * hour) { promises.push(event.data.ref.child('tickets').set(maxTickets)); promises.push(event.data.ref.child('time_counter').set(0)); } else if (time_counter > 6 * hour) { let addTicket = 0; while (time_counter > 6 * hour) { time_counter -= 6 * hour; ++addTicket; } promises.push(event.data.ref.child('tickets').set(previous.tickets + 1)); promises.push(event.data.ref.child('time_counter').set(time_counter)); } else promises.push(event.data.ref.child('time_counter').set(time_counter)); return Promise.all(promises); });
Écrire dans Firebase est en grande partie asynchrone. Même si Firebase propose des cloud functions, il peut être délicat d’exécuter certaine logique avec des données serveur, voici du code pour sauvegarder le meilleur temps sur un niveau : si “time” existe déjà côté serveur, on le compare. Écrit de cette façon, on est sûr qu’il n’y aura aucun problème d’écriture concurrente.
Un email par mois maximum : nos articles techniques, nos retours de projets et les nouvelles sessions de formation. Pas de spam, désinscription en un clic.
En vous inscrivant, vous acceptez de recevoir nos communications. Vos données ne sont jamais cédées à des tiers.
✓ Merci ! Vous êtes inscrit·e à la newsletter Da Viking Code.