Partez à l’aventure et déterrez le trésor caché tout en prenant garde aux pièges dissimulés aux alentours ! Personnalisez également votre expérience grâce à trois seuils de difficulté et des cartes générées aléatoirement. Aurez-vous l’audace de vous mesurer à ce jeu de logique inspiré du démineur ?
Cliquez sur ce lien afin d’accéder aux règles du jeu.
Un projet sous couche graphique
Bien avant que le projet libre soit annoncé, j’avais créé un jeu de chasse au trésor sur Python, sous couche textuelle. La console de mon IDE affichait les différentes cases grâce à un print() et l’utilisateur devait entrer les coordonnées de la case à retourner.
Cette façon de procéder, bien qu’utile afin de tester un concept de jeu, demeure tout de même lente et peu accessible. Un jeu comme celui du démineur doit fournir un moyen rapide d’interagir avec lui. C’est la raison pour laquelle j’ai décidé de rénover ce jeu sous couche graphique, c’est-à-dire avec des sprites au lieu de chaînes de caractères.
Les bibliothèques Python
Tout au long du code sont exploitées deux bibliothèques Python : kandinsky et ion.
La bibliothèque kandisky propose deux fonctions utiles afin d’afficher des sprites et du texte : fill_rect(), qui trace un rectangle, et draw_string(), qui affiche du texte.
La bibliothèque ion, quant à elle, permet de détecter les touches poussées sur la calculatrice.
Le bout de code suivant importe les fonctions utiles de ces bibliothèques et crée des diminutifs afin de les appeler, en l’occurrence « fr » pour « fill_rect » et « ds » pour « draw_string » :
from kandinsky import fill_rect as fr
from kandinsky import draw_string as ds
from ion import keydown
Afin d’avoir un aperçu de mon code sur la calculatrice, j’ai installé sur mon IDE les plugins ion-numworks et kandinsky grâce à ce tutoriel.
La logique du jeu
Afin de bien coder ce jeu, il fallait que je comprenne la logique derrière celui-ci.
En effet, le jeu est divisé en deux plateaux : le plateau visible et le plateau caché.
En effet, le plateau visible est une grille de dimensions 5 x 5 remplie de chiffres. Le plateau caché, grille de mêmes dimensions, contient quant à lui le trésor, les piques et les couleurs. Les deux plateaux interagissent entre eux.
Le plateau caché serait initialisé en premier. Il recevrait le trésor et on placerait les couleurs en fonction de l’emplacement du trésor. Enfin, on positionnerait les piques et on changerait trois couleurs afin de brouiller les pistes.
Ensuite, on initialiserait le plateau visible et placerait les nombres en fonction de l’emplacement des piques. De ce fait, à chaque fois que le joueur retournera une case, une couleur, un pique ou le trésor sera affiché.
L’initialisation des plateaux
La réalisation du programme a commencé par la fonction init_plateau() qui, comme son nom l’indique, initialise les plateaux. Le plateau caché, dans le script, est représenté par la variable plateau_inf qui est une liste composée de cinq listes elles-mêmes composées de cinq éléments :
plateau_inf = [["V", "V", "V", "V", "V"],
["V", "V", "V", "V", "V"],
["V", "V", "V", "V", "V"],
["V", "V", "V", "V", "V"],
["V", "V", "V", "V", "V"]]
Ici, les listes sont remplies par la lettre « V » afin de ne pas avoir à placer la couleur verte. En effet, après avoir placé le trésor, les piques, les cases rouges et les cases orange, il ne reste plus que les cases vertes qui n’ont pas été touchées.
Ensuite, on place le trésor. Pour cela, j’utilise le module random et en particulier sa fonction randint() afin de choisir aléatoirement les coordonnées de la case :
def init_plateau():
# -------------------- plateau inférieur
# placement du trésor
tresor_x, tresor_y = randint(0, 4), randint(0, 4)
plateau_inf[tresor_x][tresor_y] = "X"
Puis, on positionne les couleurs. Puisque la variable plateau_inf est remplie de « V », il nous suffit de placer des « R » et des « O » (pour rouge et orange).
Le bout de code suivant peut être plutôt hermétique. Ce dernier vérifie si les huit cases autour du trésor sont bien à l’intérieur de la grille, et si c’est le cas, on y place un « R ». Le même processus est adopté pour les seize cases autour des cases rouges afin d’y ajouter un « O » :
def init_plateau():
# -------------------- plateau inférieur
# placement du trésor
# [...]
# placement des cases rouges et orange
for i in range(tresor_x - 2, tresor_x + 3):
for j in range(tresor_y - 2, tresor_y + 3):
if 0 <= i <= 4 and 0 <= j <= 4 and (i, j) != (tresor_x, tresor_y):
if abs(tresor_x - i) <= 1 and abs(tresor_y - j) <= 1:
plateau_inf[i][j] = "R"
elif abs(tresor_x - i) <= 2 and abs(tresor_y - j) <= 2:
plateau_inf[i][j] = "O"
Ensuite, on place les piques en prêtant bien attention à ce qu’un pique ne tombe pas sur le trésor et que deux piques ne soient pas sur la même case. On utilise la variable global nb_piques qui pourra être modifiée en fonction de la difficulté choisie :
nb_piques = [11]
def init_plateau():
# -------------------- plateau inférieur
# placement du trésor
# [...]
# placement des cases rouges et orange
# [...]
# placement des piques
for _ in range(nb_piques[0]):
while True:
pique_x = randint(0, 4)
pique_y = randint(0, 4)
if plateau_inf[pique_x][pique_y] != "S" and plateau_inf[pique_x][pique_y] != "X":
plateau_inf[pique_x][pique_y] = "S"
break
Enfin, on met en place les fausses couleurs. Ce bout de code s’appuie sur beaucoup de conditions : il faut que la case choisie au hasard soit une case de couleur, et non un pique ou le trésor, et que cette case ne soit pas déjà une case colorée par une fausse couleur. Si ces conditions sont remplies, on peut changer la couleur de la case choisie :
def init_plateau():
# -------------------- plateau inférieur
# placement du trésor
# [...]
# placement des cases rouges et orange
# [...]
# placement des piques
# [...]
# placement des fausses couleurs
for _ in range(nb_fausses[0]):
fausses_originales = []
while True:
fausse_x, fausse_y = randint(0, 4), randint(0, 4)
if plateau_inf[fausse_x][fausse_y] != "S" and plateau_inf[fausse_x][fausse_y] != "X":
if (fausse_x, fausse_y) not in fausses_originales:
fausses_originales.append((fausse_x, fausse_y))
while True:
espace_nouv = randint(0, 2)
if espace_nouv == 0 and plateau_inf[fausse_x][fausse_y] != "V":
plateau_inf[fausse_x][fausse_y] = "V"
break
elif espace_nouv == 1 and plateau_inf[fausse_x][fausse_y] != "O":
plateau_inf[fausse_x][fausse_y] = "O"
break
elif espace_nouv == 2 and plateau_inf[fausse_x][fausse_y] != "R":
plateau_inf[fausse_x][fausse_y] = "R"
break
break
Et voilà ! Nous avons fini d’initialiser le plateau caché. Il ne reste plus que le plateau visible. Pas d’inquiétudes : celui-ci prend moins de temps à programmer. Il suffit de parcourir le plateau caché, et, lorsque l’on rencontre un pique, on ajoute 1 aux cases adjacentes du plateau visible (si elles se trouvent dans la grille) :
plateau_sup = [[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0]]
def init_plateau():
# -------------------- plateau inférieur
# placement du trésor
# [...]
# placement des cases rouges et orange
# [...]
# placement des piques
# [...]
# placement des fausses couleurs
# [...]
# -------------------- plateau supérieur
for ligne in range(0, 5):
for colonne in range(0, 5):
if plateau_inf[ligne][colonne] == "S":
if ligne < 4: plateau_sup[ligne + 1][colonne] += 1
if colonne < 4: plateau_sup[ligne][colonne + 1] += 1
if ligne > 0: plateau_sup[ligne - 1][colonne] += 1
if colonne > 0: plateau_sup[ligne][colonne - 1] += 1
La programmation du gameplay
À présent que les plateaux sont programmés, il faut permettre au jouer d’interagir avec eux. Cela se fait grâce à la fonction jeu().
Cette fonction peut être décomposée en deux parties : l’initialisation et la boucle.
Commençons par l’initialisation. Celle-ci permet de tracer la grille et afficher les chiffres et couleurs à l’écran :
def jeu():
x, y = 0, 0
reset()
init_plateau()
fr(0, 0, 320, 240, 'white')
grille()
La fonction jeu() commence par instancier les coordonnées du curseur déplaçable. Ensuite, elle réinitialise les plateaux et autres variables grâce à la fonction reset(), appelle la fonction init_plateau() vue précédemment et trace une grille grâce à la fonction grille() (tirée de ce script).
def reset():
for x in range(5):
for y in range(5):
plateau_sup[x][y] = 0
plateau_inf[x][y] = 'G'
cases_retournees.clear()
cases_marquees.clear()
vies[0] = 3
def grille():
for i in range(6):
if i < 6:
fr(70, 31 + 35 * i, 176, 1, 'black')
fr(70 + 35 * i, 31, 1, 176, 'black')
Souvenez-vous : « fr » est le diminutif de la fonction fill_rect() du module kandinsky. En vérité, on trace une multitude de rectangles très fins qui se croisent afin de créer la grille.
Les variables cases_retournees, cases_marquees et vies présentes dans la fonction reset() seront expliquées juste après.
Nous voilà à présent dans la boucle de la fonction. Cette boucle non-bornée se répète continuellement grâce aux mots-clefs « while True » :
vies = [3]
def jeu():
# [...]
while True:
fr(100, 10, 130, 15, 'white')
ds("<3\t" * vies[0], 88, 8, 'black')
affiche()
Ici, le nombre de vies est affiché grâce à la variable globale vies. Entre autres, la fonction affiche() est appelée :
rouge = (249, 65, 68)
orange = (243, 114, 44)
vert = (144, 190, 109)
cases_retournees = []
cases_marquees = []
def affiche():
for coord1 in cases_marquees:
fr(71 + 35 * coord1[0], 32 + 35 * coord1[1], 35, 2, rouge)
fr(71 + 35 * coord1[0], 32 + 35 * coord1[1], 2, 35, rouge)
fr(71 + 35 * coord1[0], 64 + 35 * coord1[1], 35, 2, rouge)
fr(103 + 35 * coord1[0], 32 + 35 * coord1[1], 2, 35, rouge)
for coord2 in cases_retournees:
if plateau_inf[coord2[0]][coord2[1]] == 'G':
couleur = vert
elif plateau_inf[coord2[0]][coord2[1]] == 'O':
couleur = orange
elif plateau_inf[coord2[0]][coord2[1]] == 'R':
couleur = rouge
elif plateau_inf[coord2[0]][coord2[1]] == 'S':
couleur = 'grey'
fr(71 + 35 * coord2[0], 32 + 35 * coord2[1], 34, 34, couleur)
Cette fonction, en premier lieu, parcourt la liste cases_marquees et ajoute un contour rouge à chaque case dont les coordonnées sont présentes dedans. Ce contour est composé de quatre rectangles fins qui forment un carré.
affiche() parcourt également la liste cases_retournees qui retient les cases dont il faut afficher la couleur si elles ne cachent pas un pique. En fonction du contenu du plateau caché, la fonction attribue une couleur différente à chaque case.
Cette première partie de la boucle se termine par l’affichage des nombres sur la grille. Cependant, la tâche n’est pas aussi aisée qu’en apparence. En effet, lorsqu’une ligne de texte est affichée grâce à la fonction draw_string() (représentée ici par « ds »), son fond est blanc par défaut.
Seulement, si une case est retournée et est donc colorée, afficher un chiffre sur fond blanc dessus est peu esthétique :
Il faut donc changer le fond de la ligne de texte affichée en fonction de la case sur laquelle elle se trouve :
def jeu():
# [...]
for a in range(0, 5):
for b in range(0, 5):
couleur = 'white'
if (a, b) in cases_retournees:
if plateau_inf[a][b] == 'G':
couleur = vert
elif plateau_inf[a][b] == 'O':
couleur = orange
elif plateau_inf[a][b] == 'R':
couleur = rouge
elif plateau_inf[a][b] == 'S':
couleur = 'grey'
ds(str(plateau_sup[a][b]), 83 + 35 * a, 41 + 35 * b, 'black', couleur)
Les boucles bornées imbriquées l’une dans l’autre génèrent toutes les coordonnées possibles sur une grille de dimensions 5 x 5. Ensuite, on observe lesquelles de ces coordonnées correspondent à des cases retournées, et on colorie le fond du texte en fonction de la case.
Enfin, passons au cœur de la boucle. Il y a beaucoup d’éléments à analyser.
def jeu():
# [...]
while True:
curseur(x, y, jaune)
x_bis, y_bis = x, y
touche = attente([0, 1, 2, 3, 4, 52, 17])
La boucle infinie commence par afficher le curseur du joueur aux bonnes cordonnées grâce à la fonction curseur() :
def curseur(x, y, c):
fr(70 + 35 * x, 31 + 35 * y, 35, 1, c)
fr(70 + 35 * x, 31 + 35 * y, 1, 35, c)
fr(70 + 35 * x, 66 + 35 * y, 35, 1, c)
fr(105 + 35 * x, 31 + 35 * y, 1, 35, c)
Ce curseur est formé de quatre rectangles fins.
Ensuite, la boucle utilise la fonction attente() (tirée de ce script) afin d’enregistrer les touches poussées par le joueur :
def attente(touches):
while True:
for nb in touches:
if keydown(nb):
while keydown(nb):
True
return nb
Pourquoi ne pas simplement utiliser la fonction keydown() fournie par le module ion ? C’est tout bonnement simple : on veut détecter la touche poussée par l’utilisateur lorsque celui-ci relâche la touche. En effet, en utilisant keydown(), si le joueur reste appuyé sur une touche, alors, avec la boucle non-bornée, le mouvement serait répété plusieurs fois. Cela rendrait l’expérience de jeu ingérable puisque le curseur se déplacerait sur la longueur ou largeur de la grille au moindre appui de touche. Il nous faut donc attendre que l’utilisateur relâche la touche, et c’est ce que nous permet la fonction attente().
En ce qui concerne le reste de la fonction jeu(), selon la touche poussée, on effectue différentes actions.
def jeu():
# [...]
if touche == 4 or touche == 52:
if (x, y) not in cases_retournees:
cases_retournees.append((x, y))
verif(x, y)
break
Dans le cas ou le joueur appuie sur la touche [OK], on s’assure que la case sur laquelle il se trouve n’est pas déjà retournée, et, dans ce cas, on la retourne. Enfin, on appelle la fonction verif() qui vérifie l’état de la partie, c’est-à-dire si le joueur a gagné, perdu, ou s’il continue de jouer :
def verif(x, y):
if plateau_inf[x][y] == 'X':
fr(80, 60, 160, 100, 'black')
ds("Bien joué !", 110, 100, 'white', 'black')
attente([4, 52, 17])
menu()
elif plateau_inf[x][y] == 'S':
vies[0] -= 1
if vies[0] < 1:
fr(90, 10, 130, 15, 'white')
fr(80, 60, 160, 100, 'black')
ds("Perdu !", 130, 100, 'white', 'black')
attente([4, 52, 17])
menu()
Si le joueur a retourné la case qui cachait le trésor, on le ramène au menu ; si, en revanche, celui-ci tombe sur un pique, on lui retire une vie, puis, on jette un coup d’œil au nombre de vies qu’il lui reste, et s’il est à zéro, on ramène le joueur au menu. La fonction menu() sera présentée plus tard.
De retour à la boucle. Si le joueur appuie sur la touche [DEL], on s’assure que la case sur laquelle il se trouve n’est pas déjà marquée, et, dans ce cas, on la marque.
def jeu():
# [...]
elif touche == 17:
if (x, y) not in cases_marquees:
cases_marquees.append((x, y))
break
Les deux bouts de code de la boucle présentés contiennent tous deux le mot-clef « break ». Ce dernier permet de sortir de la boucle secondaire afin de revenir dans la boucle principale de sorte que l’écran puisse être « rafraîchi », c’est-à-dire que les sprites soient mis à jour.
Enfin, en fonction de la flèche de navigation poussée par le joueur, on change les coordonnées de son curseur si celui-ci ne s’évade pas de la grille, puis on colore en noir l’emplacement précédent du curseur.
def jeu():
# [...]
elif x > 0 and touche == 0:
x -= 1
elif x < 4 and touche == 3:
x += 1
elif y > 0 and touche == 1:
y -= 1
elif y < 4 and touche == 2:
y += 1
curseur(x_bis, y_bis, 'black')
Et nous voilà à la fin de la fonction jeu(). Le plus dur est dernière nous !
La mise en place du menu principal
La fonction menu() permet d’afficher les différents seuils de difficulté et d’accéder au tutoriel. Cette fonction est la première a être appelée.
Tout comme la fonction jeu(), menu() est composée d’une initialisation et d’une boucle.
violet = (148, 113, 222)
choix = 0
def menu():
global choix
fr(0, 0, 320, 240, 'white')
fr(0, 200, 320, 40, jaune)
ds('Chasse au trésor', 80, 20, 'black')
ds('-----------------', 75, 40, 'black')
ds("Code by nsi.xyz/chasse-au-tresor", 0, 202,'white', violet)
Durant l’initialisation, on affiche tout simplement quelques lignes de texte telles que le titre du jeu. Rien de bien compliqué. Il serait cependant intéressant de faire remarquer que la variable choix est placée hors de la fonction avant d’être rendue globale grâce au mot-clef « global ».
Pourquoi donc ? Eh bien, puisque la variable est hors de la fonction, sa valeur n’est pas écrasée lorsque l’on appelle à nouveau la fonction. Ainsi, le jeu peut se souvenir du choix du joueur lorsque celui-ci navigue parmi les options.
Passons à présent à la boucle de la fonction :
choix_couleurs = {0: ('Facile', 128, 70, vert),
1: ('Moyen', 133, 100, orange),
2: ('Difficile', 115, 130, rouge),
3: ('Tutoriel', 118, 160, jaune)}
def menu():
# [...]
while True:
for i in range(4):
texte, x, y, couleur = choix_couleurs[i]
ds(texte, x, y, couleur if i == choix else 'black')
Cette partie de la boucle n’est pas aussi compliquée qu’il n’y paraît. En effet, selon le choix du joueur, elle affiche les quatre options disponibles dans différentes couleurs en exploitant un dictionnaire. Cela permet donc à l’utilisateur de visualiser ce qu’il s’apprête à faire.
La seconde partie de la boucle est également simple. Si le joueur appuie sur la flèche du haut ou du bas, la variable choix est mise à jour. Les conditions supplémentaires aux lignes 4 et 6 permettent de ne pas sortir des quatre options possibles :
def menu():
# [...]
touche = attente([1, 2, 4, 52])
if touche == 1 and choix > 0:
choix -= 1
elif touche == 2 and choix < 3:
choix += 1
elif touche == 4 or touche == 52:
if choix == 0:
nb_piques[0] = 11
jeu()
if choix == 1:
nb_piques[0] = 13
jeu()
if choix == 2:
nb_piques[0] = 15
jeu()
if choix == 3:
tutoriel()
En revanche, si l’utilisateur pousse la touche [OK], on modifie le nombre de piques selon la difficulté choisie, puis on se rend au jeu ou tutoriel. Et voilà !
Mot de fin
Créer ce projet sous couche graphique sur la calculatrice a été une expérience enrichissante. Non seulement ai-je été obligé de bien réfléchir à la logique de mon jeu en accord avec celle de mon programme, mais j’ai également dû coder avec les contraintes de mémoire de la calculatrice.
Mon script, j’en suis certain, pourrait être bien davantage optimisé ; mais je pense avoir fait du mieux que j’ai pu dans le temps imparti.
Je tiens également à faire remarquer que je n’ai pas abordé la fonction tutoriel() étant donné que celle-ci n’est composée que de lignes de texte affichées par la fonction draw_string(), et n’est par conséquent pas des plus captivantes à analyser.
Merci d’avoir lu jusqu’ici !
Lien
Voici le lien du jeu qui amène à mon workshop :
Étudiant en spécialité NSI en classe de terminale de 2022 à 2023 au lycée Louis Pasteur.