Précédent Tutoriels Suivant

Générateur de sol pour Lunar-Landing

Créé le : 2017-12-07
Dernière mise à jour : 2020-10-08


Il s'agit d'un tutoriel réalisé principalement pour les élèves du site Gamecodeur.

Dans un des ateliers de Gamecodeur, nous devions réaliser le déplacement d'un petit vaisseau spatial pour comprendre l'utilisation de la vélocité.
A la fin de cet atelier, nous obtenions un petit jeu de type "Lunar-Landing" où le bas de l'écran servait de sol.


Afin de rendre ce petit jeu un peu plus attrayant, j'ai décidé de générer le sol de manière aléatoire.


Objectifs

L'objectif de ce tutoriel est de vous expliquer les différentes étapes dans un jeu de type "Lunar-Landing" pour :


Première étape : portion de l’écran occupé

Image1 Tout d'abord, il faut définir quelle portion de l'écran va occuper votre sol. J'ai choisi une hauteur de maximum 80 pixels. Cela nous donne un sol de cette hauteur (représentée par la ligne pointillée orange) dans la figure ci-contre.Il suffit simplement de définir une variable dans la fonction love.load() comme ci-dessous :

function love.load()
  largeur = love.graphics.getWidth()
  hauteur = love.graphics.getHeight()
  minTerrain = hauteur - 80
-- votre code
end

Deuxième étape : la création du terrain

Il faudrait, comme sur la plupart des planètes, avoir un sol avec une dénivellation irrégulière. Plein de bosses, pics ou trous, quoi (^_^) Tout d’abord, on va créer une variable Terrain qui accueillera les données :

local Terrain = {}
Terrain.X = {}
Terrain.Y = {}

Ainsi qu’une nouvelle fonction pour effectuer la construction du terrain :

function ConstructTerrain()
end

Il nous faut maintenant choisir un point sur la gauche de l’écran (rndX = 0) situé dans la zone délimitée par la ligne pointillée orange et le bas de l’écran. Cela nous donnera notre départ pour le sol.

local rndY = math.random(minTerrain, hauteur)

On découpe l’écran en plusieurs sections. Cela a pour effet de simuler un environnement un peu chaotique. J’ai choisi 20 sections pour que chaque tronçon fasse environ 30 pixels de large sur la fenêtre native. Donc, on effectue une boucle sur le nombre de sections + 1 (rappel : pour tracer une ligne, il faut obligatoirement 2 points). Pour chaque point, on stocke ses coordonnées X et Y dans Terrain comme ceci :


rndX = 0
rndY = math.random(minTerrain, hauteur)
for ind=1,20 + 1 do
  Terrain.X[ind] = rndX
  Terrain.Y[ind] = rndY
  rndX = (largeur / 20) + rndX
  rndY = math.random(minTerrain, hauteur)
end

Normalement, on a tous les points nécessaires pour afficher notre sol, ce que nous allons faire maintenant. Si vous constatez que la dernière section n’est pas affichée, il suffit de rajouter les lignes suivantes après la boucle :

Terrain.X[#Terrain.X + 1] = largeur
Terrain.Y[#Terrain.Y + 1] = math.random(minTerrain, hauteur)

A ce stade-ci, vous devriez avoir obtenu la fonction suivante :

function ConstructTerrain()
  Terrain.X = {}
  Terrain.Y = {}
  local ind = 1
  local rndX = 0
  local rndY = math.random(minTerrain, hauteur)
  for ind=1,20 + 1 do
    Terrain.X[ind] = rndX
    Terrain.Y[ind] = rndY
    rndX = (largeur / 20) + rndX
    rndY = math.random(minTerrain, hauteur)
  end
  Terrain.X[#Terrain.X + 1] = largeur
  Terrain.Y[#Terrain.Y + 1] = math.random(minTerrain, hauteur)
end

Personnellement, j’ai pris l’habitude de créer une fonction pour dessiner le niveau (ici, notre sol) :

function drawTerrain()
  local ind = 1
  for ind=1,#Terrain.X - 1 do
    love.graphics.line(Terrain.X[ind],Terrain.Y[ind],Terrain.X[ind + 1],Terrain.Y[ind + 1])
  end
end

Bien entendu, il faut ajouter l’appel de la fonction drawTerrain à la fonction love.draw. Maintenant, vous pouvez lancer votre jeu. Vous obtenez un sol plutôt pas mal, non ?


Image2"

Et si vous relanciez une seconde fois votre jeu, que voyez-vous ? Rien ? Vous ne remarquez pas que c’est exactement le même terrain que la dernière fois ? Cela semble être un petit détail mais cela peut faire qu’un joueur se lasse du jeu (il finit par connaitre les niveaux par cœur à la fin). On va rajouter une simple ligne au début de la fonction love.load :

math.randomseed(os.time())

Cette fonction permet de définir à partir de quelle valeur doit être calculée la liste de valeurs pseudo-aléatoires. Je ne vais pas rentrer dans les détails parce que je ne les connais pas. Pour faire simple, si vous ne changez pas le « seed » du random, vous obtiendrez toujours la même liste de chiffres aléatoires. Voilà, votre terrain est prêt ! Il ne reste plus qu’à vérifier si le vaisseau entre en collision avec le terrain (en tout cas, pas à ce stade-ci du tutoriel ^^)


Image3


Création du terrain - Bonus

Personnellement, je trouve que ce sol n’est pas assez réaliste. Cela manque d’un peu plus de chaos. Tout d'abord, je fais varier le nombre de sections possibles entre 15 et 30.

local rndIter = math.random(15, 30)

Pour l’explication, je me sers du nombre de sections pour définir la largeur maximale que pourrait avoir une section. Ensuite, on prend une valeur de manière aléatoire comprise entre le point précédent (rndX) et une distance de 2 sections de l’écran (calculé dans nextMaxX). Cela permet d’avoir une variation dans la taille des sections. J’ai pris une taille de 2x la largeur d’une section pour augmenter les différences.

Ne pas oublier d’ajouter rndX dans nextMaxX sinon on obtient des choses très bizarres (^_^)

local nextMaxX = (largeur / rndIter) * 2 + rndX
rndX = math.random(rndX, nextMaxX)

Ici, on attaque une partie un peu plus délicate : on change de type de boucle. On supprime la boucle for pour la remplacer par la suivante :

while (rndX < largeur) do

Comme on a une dimension de section aléatoire, on ne sait pas si on arrive avant ou au-delà de la largeur de l’écran. Seule donnée qui nous indique où sera situé le prochain point : rndX.

Une dernière chose et la plus importante de toute : il faut incrémenter la variable ind. Sans cela, vous obtiendrez, comme moi lors de mes essais, juste la dernière section affichée à l’écran (^_^)

ind = ind + 1

Vous devriez obtenir un sol beaucoup plus chaotique que le précédent. Voici ma version finale :

function ConstructTerrain()
  Terrain.X = {}
  Terrain.Y = {}
  local rndMaxLargeur = (largeur / math.random(15, 30)) * 2
  local rndX = 0
  local rndY = math.random(minTerrain, hauteur)
  local ind = 1
  while (rndX < largeur) do
      Terrain.X[ind] = rndX
      Terrain.Y[ind] = rndY
      rndX = math.random(rndX, rndMaxLargeur + rndX)
      rndY = math.random(minTerrain, hauteur)
      ind = ind + 1
  end
  Terrain.X[#Terrain.X + 1] = largeur
  Terrain.Y[#Terrain.Y + 1] = math.random(minTerrain, hauteur)
end

Avant « chaotisation » :

Image4

Après « chaotisation » :

Image5

Troisième étape : la position du terrain sous le vaisseau

Math

Tout d’abord, on doit déterminer au-dessus de quelle section se trouve le vaisseau. Quand je parle de « au-dessus », c’est une indication uniquement visuelle. La réelle position du vaisseau est en dessous du sol. Dans cette partie, on va faire un gros cours de mathématiques…mais à ma façon (^_^) Le premier objectif est de trouver la coordonnée du point d’intersection entre le vaisseau et le sol, représenté dans la figure 2 par le point P. Pour rappel : on ne possède que les coordonnées des points de début et de fin de chaque section, soit les points A et B dans la figure 2. Pour cela, on doit calculer la coordonnée de ce point P. Un petit schéma devrait vous expliquer un peu mieux mes propos :


On sait que la coordonnée X du point P est identique à celle du vaisseau : xp = xvaisseau. Pour calculer la coordonnée Y, il doit exister une formule mathématique appropriée (j’en suis même sûr) mais je ne la connais pas. Ce que j’ai fait est uniquement basé sur les pourcentages (ou produit en croix).



En premier lieu, il faut connaître sous quelle section se situe le vaisseau. Pour cela, il suffit de balayer la liste des Terrain.X comme indiqué ci-dessous :

function SearchSection(posX)
  local ind=1
  for ind=1,#Terrain.X - 1 do
    if Terrain.X[ind] <= posX and posX <= Terrain.X[ind + 1] then
      return ind
    end
  end
--  Il existe 2 cas où cette fonction retourne 0 :
--      posX < 0 OU posX > Terrain.X[ind + 1]
--  Ici, Terrain.X[ind + 1] correspond à la largeur de l’écran
  return 0
end

Ensuite, j’ai calculé la distance entre le point de la fin de la section (point B) et le point du début de la section (point A). Pour cela, j’ai simplement effectué une soustraction :

local dx = Terrain.X[debSection + 1] - Terrain.X[debSection]

J’ai reproduit cette méthode pour obtenir la distance entre le vaisseau et le point A :

local as = posX - Terrain.X[debSection]

Ici, posX correspond à la position en X du vaisseau.


A partir de ces 2 résultats, on obtient le coefficient qui permettra de trouver la coordonnée Y du point P. Si on multiplie ce coefficient avec la distance entre le point B et le point A en Y, on obtient la distance ap de la figure 2 :

local dy = Terrain.Y[debSection   1] - Terrain.Y[debSection]
local ap = dy * (as / dx)

Il ne reste plus qu’à rajouter la position Y du point A au résultat obtenu (ici, ecart) et nous avons la coordonnée Y du point P. Vous devriez obtenir ceci :

function getTerrainHauteur(posX)
  local debSection = SearchSectionX(posX)
  local dx = Terrain.X[debSection + 1] - Terrain.X[debSection]
  local as = posX - Terrain.X[debSection]
  local dy = Terrain.Y[debSection + 1] - Terrain.Y[debSection]
  local ap = dy * (as / dx)
  return Terrain.Y[debSection] + ap
end

Ayant pour habitude de découper mon code en plusieurs fichiers, j’ai tendance à un peu « abuser » des fonctions (^_^)




Quatrième étape : la collision avec le vaisseau

Dans cette étape, on effectue la majorité des modifications dans la fonction love.update.
Tout d’abord, on recherche la position du terrain avec la fonction créé dans l’étape précédente :

local pY = getTerrainHauteur(lander.x)

Ici, il y a une petite subtilité énoncée par David dans son atelier : le centre du vaisseau est au centre de l’image. On doit donc ajouter la moitié de l’image à la position Y du vaisseau, ce qui correspond à la position de l’engin :

local posEngine = lander.y + lander.img:getHeight()/2

On vérifie ensuite si le point P et la position du vaisseau (posEngine) sont éloignés de moins de 3 pixels. J’ai trouvé que 3 pixels donnaient un résultat satisfaisant lors de mes tests mais vous pouvez l’augmenter pour faciliter l’atterrissage.

if (pY - posEngine) < 3 then

On teste également que la vitesse du vaisseau ne soit pas trop importante. Au cours de mes tests, j’ai trouvé que 0.3 était suffisant. Vous pouvez augmenter ou diminuer cette valeur pour augmenter ou diminuer la difficulté.

if lander.vy < 0.3 then

A ce niveau, il ne reste plus qu’à « poser » notre vaisseau. Pour cela, on doit créer plusieurs variables. La première étant celle qui indiquera si le vaisseau a atterri ou non. Je l’ai appelée « landed » mais vous pouvez utiliser le nom de votre choix, pensez juste à faire le remplacement aux autres endroits dans le code (^_^)

lander.landed = true

On désactive également le moteur pour qu’il ne s’affiche plus :

lander.engineOn = false

On annule la vitesse du vaisseau et, pour être sûr que le vaisseau soit bien contre le sol, on force sa position en Y :

    lander.vx = 0
    lander.vy = 0
    lander.y = pY - lander.img:getHeight()/2

Rappel : on enlève la moitié de l’image car le centre du vaisseau est au centre de l’image.

Enfin, si la vitesse est trop importante (le else), on repositionne le vaisseau à sa position de départ :

    lander.x = largeur/2
    lander.y = hauteur/2


Au final,vous devriez obtenir quelque chose qui ressemble à ceci :

local pY = getTerrainHauteur(lander.x)
local posEngine = lander.y + lander.img:getHeight()/2
if (pY - posEngine) < 3 then
  if lander.vy < 0.3 then
    lander.landed = true
    lander.engineOn = false
    lander.vx = 0
    lander.vy = 0
    lander.y = pY - lander.img:getHeight()/2
  else
    lander.x = largeur/2
    lander.y = hauteur/2
  end
end

Voilà, ce petit tutoriel est fini.

Voici, ci-dessous quelques propositions d’améliorations possibles :