CSC 4103 – Programmation système

Portail informatique

Démineur

Dans cette première session de TP, l'objectif est de se familiariser avec le langage C, d'écrire un premier programme simple, le compiler et l'exécuter, et de savoir se documenter face à une fonction dont on ne connaît pas le comportement.

En plus de votre encadrant de TP, la commande man unefonction vous en apprendra plus sur la fonction unefonction, notamment quel fichier inclure avec #include, et comment l'utiliser.

Dans ce TP, nous allons créer un Démineur. Ce jeu se joue sur une carte (ici de taille 6 x 6) où certaines cases contiennent des bombes cachées. L'utilisateur doit retrouver toutes les bombes. Pour cela, il peut:

  • Tester une case. Si cette case contient une bombe, l'utilisateur a perdu. Sinon, la case indique le nombre de bombes dans les 8 cases voisines;
  • Placer un Drapeau sur une case pour indiquer qu'il pense qu'une bombe y est cachée. Lorsque l'utilisateur a identifié l'emplacement de toutes les bombes, il a gagné.

Voici un exemple de déroulement du jeu:

$ ./demineur Quel est votre nom ? Alice Bienvenue 'Alice'. Tour 0 -- Alice 0 1 2 3 4 5 0 1 2 3 4 5 Placer un drapeau (D) ou tester (T) ? T Quelle ligne ? (0-6) 3 Quelle colonne ? (0-6) 3 Tour 1 -- Alice 0 1 2 2 1 1 3 2 0 0 4 2 0 0 5 0 1 2 3 4 5 Placer un drapeau (D) ou tester (T) ? T Quelle ligne ? (0-6) 4 Quelle colonne ? (0-6) 1 Tour 2 -- Alice 0 1 2 2 1 1 3 2 0 0 4 3 2 0 0 5 0 1 2 3 4 5 Placer un drapeau (D) ou tester (T) ? T Quelle ligne ? (0-6) 3 Quelle colonne ? (0-6) 1 Perdu Alice après 2 tours! Solution: 0 1 b 2 b 2 1 1 3 b 2 0 0 4 b 3 2 0 0 5 b 0 1 2 3 4 5 Votre carte: 0 1 2 2 1 1 3 2 0 0 4 3 2 0 0 5 0 1 2 3 4 5

Mise en place du jeu

Créer, avec votre éditeur de code, le fichier demineur.c. Vous écrirez dans ce fichier un programme qui demande son nom à l'utilisateur, stocke son nom dans la variable globale char nom[128] et affiche un message de bienvenue personnalisé (par exemple"Bienvenue Alice !"
$ emacs demineur.c & Corrigé
La fonction scanf est capricieuse et appeler scanf("%d", nom); peut poser problème à cause du caractère '\n' (ou \r\n sur certaines plates-formes). Nous vous suggérons donc de lire le nom avec:
char buffer[128]; fgets(buffer, 128, stdin); sscanf(buffer, "%s", nom);

Compilez ce programme, puis exécutez le.
$ gcc demineur.c -o demineur -Wall -Werror $ ./demineur Quel est votre nom ? Alice Bienvenue Alice!

Création de la carte de jeu

Le jeu se déroulera sur une "carte" de 6x6 cases. Chaque case sera représentée par un char. Le programme utilisera deux cartes:
  • carte_solution: la carte qui contient l'emplacement des bombes. Cette carte ne sera pas affichée, et elle sera utilisée pour vérifier si le joueur teste une case qui cache une bombe. Chaque case de la carte pourra contenir :
    • 'b' : la case contient une bombe
    • ' ' : la case ne contient pas de bombe
  • carte_utilisateur: la carte affichée à l'utilisateur. Cette carte contient les cases qui ont été révélées. Chaque case de la carte pourra contenir:
    • '0'-'8': la case a été testée par l'utilisateur et elle ne contient pas de bombe. Le nombre indiqué révèle le nombre de bombes dans les 8 cases voisines.
    • 'D': l'utilisateur a placé un Drapeau sur cette case car il soupçonne la présence d'une bombe
    • ' ': la case n'a pas été testée.

Ajoutez les lignes suivantes au début du programme demineur.c:

#define NB_LIGNES 6 #define NB_COLONNES 6 #define NB_BOMBES 5 typedef char carte_t[NB_LIGNES][NB_COLONNES];
Ces lignes définissent des constantes et types que vous devrez utiliser dans la suite de l'exercice.

Le type carte_t est un tableau à 2 dimensions, avec NB_LIGNES et NB_COLONNES colonnes. C'est-à-dire que carte_t est un tableau contenant NB_LIGNES éléments, où chaque élément est un tableau de NB_COLONNES int. Il s'indexe dans le même ordre : pour y accéder, le premier indice donne la ligne (l'ordonnée dans la carte), et le second indice donne la colonne (l'abscisse dans la carte).

Créez une structure struct demineur contenant:

  • carte_solution et carte_utilisateur : des tableaux de caractères à deux dimensions (voir le type carte_t) de taille NB_LIGNES x NB_COLONNES
  • nb_tours: un entier servant à compter le nombre de tours de jeu

Définissez une variable globale demineur de type struct demineur.

Créez la fonction void init_demineur() qui initialise la variable demineur. Pour l'instant, cette fonction doit remplir les deux cartes avec des caractères 'x', et initialiser nb_tours à 0.

Modifiez la fonction main afin qu'elle appelle init_demineur après avoir salué l'utilisateur.

Créez la fonction void afficher_carte(carte_t carte) qui affiche le contenu de carte. En plus du contenu des cases, affichez également les numéros de lignes et de colonnes.

Modifiez la fonction main pour qu'elle affiche carte_utilisateur. Vous devriez obtenir cet affichage:

Nom du joueur : Alice Bienvenue Alice ! 0 x x x x x x 1 x x x x x x 2 x x x x x x 3 x x x x x x 4 x x x x x x 5 x x x x x x 0 1 2 3 4 5

Une fois que l'affichage de la carte fonctionne, modifiez la fonction init_demineur pour remplir les cartes avec des espaces ' ' au lieu des 'x'.

Implémentez les fonctions utilitaires suivantes:

  • int est_valide(int i, int j): vérifie si la position (i,j) est dans l'aire de jeu. La fonction retourne 1 si la position est valide, et 0 sinon.
  • char lire_val(carte_t carte, int i, int j): retourne le caractère stocké à la position (i, j) de carte
  • void ecrire_val(carte_t carte, int i, int j, char val): modifie le caractère stocké à la position (i, j) de carte
  • int est_une_bombe(int i, int j): retourne 1 si la position (i, j) de carte_solution contient une bombe. Retourne 0 sinon.
  • int jouable(int i, int j) retourne 1 si l'utilisateur est autorisé à jouer dans la case (i, j), ou 0 sinon. L'utilisateur peut jouer si la position est valide et si carte_utilisateur contient 'D' ou 'x' à cette position.

Ajoutez quelques appels à ces fonctions dans main afin de vous assurer qu'elles fonctionnent correctement.

Afin de faciliter la découverte de bugs, ajoutez des appels à la fonction assert dans les fonctions lire_val et ecrire_val.

Vous ne connaissez pas la fonction assert ? Lancez la commande man assert dans votre terminal !

Implémentez la fonction void placer_bombes() qui place NB_BOMBES dans carte_solution à des endroits aléatoires.

Pour obtenir un entier dans l'intervale [0:N[, vous pouvez utiliser rand() % N.

Avant de poser une bombe à la position (i, j), vérifiez qu'il n'y a pas déjà une bombe à cet endroit.

Implémentez maintenant la fonction void tour_de_jeu() qui effectue un tour de jeu. Ce fonction doit:

  • Afficher le nom du joueur et le numéro du tour
  • Afficher carte_utilisateur
  • Demander à l'utilisateur s'il veut placer un drapeau ou tester une case. Stocker ce choix dans une variable char choix qui peut valoir 'D' ou 'T'
  • Demander à l'utilisateur la position (i, j) qu'il souhaite tester ou sur laquelle il souhaite mettre un drapeau
  • Appeler la fonction void mettre_a_jour_cartes(char choix, int i, int j) (qui sera implémentée à la question suivante)
  • Incrémenter le champ nb_tours
Lors de la saisie de choix, i, et j, vérifiez que la valeur saisie par l'utilisateur est correcte. Si elle est incorrecte, redemandez à l'utilisateur de saisir.

Pour tester les valeurs saisies, utilisez une boucle do { ... } while(...) :

do { printf("Veuillez saisir [...]\n"); scanf(...); } while(test_valeur_saisie);

Pour un affichage plus joli, vous pouvez commencer le tour en effaçant le contenu du terminal. Pour cela, vous pouvez appeler la fonction suivante et l'appeler au début du tour:

void clear_screen() { system("clear"); }

Implémentez la fonction int ecrire_info(int i, int j). Cette fonction doit:

  • compter le nombre de bombes aux positions ([i-1:i+1], [j-1:j+1])
  • modifier la valeur de la position (i, j) sur carte_utilisateur. La nouvelle valeur sera le nombre de bombes détectées
  • retourner le nombre de bombes détectées

La valeur d'une case est de type char, mais le nombre de bombes détectées est un int compris entre 0 et 9. Pour "traduire" cet int en char, on peut utiliser le fait que les caractères ASCII des chiffres (man ascii) sont contigus: '0' correspond à la valeur décimale 48, '1' correspond à 49, etc.

On peut donc écrire:

int nb_bombes = 9; char c = nb_bombes+'0'; // valeur décimale: 57

Implémentez la fonction void mettre_a_jour_cartes(char choix, int i, int j). Cette fonction doit:

  • Si le choix vaut T:
    • Vérifier s'il y a une bombe à la position (i, j) dans carte_solution
    • S'il y a une bombe, afficher un message et carte_solution, puis terminer le programme en appelant exit
    • Sinon, appeler la fonction int ecrire_info(int i, int j). Si ecrire_info indique qu'aucune bombe n'est autour de (i, j), appeler ecrire_info pour les positions ([i-1:i+1], [j-1:j+1])
  • Si le choix vaut D:
    • Changer la valeur de la position (i, j) dans carte_utilisateur. Si la case était vide (valait ' '), sa nouvelle valeur est D. Si elle contenait un drapeau, sa nouvelle valeur est ' '
    • Appeler la fonction void test_victoire(). Cette fonction compte le nombre de drapeaux (cases 'D') dans carte_utilisateur qui correspondent à des bombes dans carte_solution. Si ce nombre correspond à NB_BOMBES, la fonction affiche un message de félicitations et termine le programme en appelant exit

Modifiez la fonction main pour qu'elle appelle tour_de_jeu() dans une boucle infinie.

En testant votre démineur, vous remarquerez sans doute que les bombes sont toujours positionnées au même endroit. Ce problème est dû au fonctionnement de la fonction rand() qui génère un nombre pseudo aléatoire à partir d'une "graine". Si on n'initialise pas ce générateur de nombres, il utilise toujours la même graine, et génère donc la même suite de nombre.

Vous pouvez rendre le jeu plus aléatoire en initialisant le générateur avec la fonction void srand(unsigned int seed). Il faut donc fournir un entier seed qui change d'une exécution du programme à l'autre. On ne peut bien sûr pas utiliser une valeur retournée par rand(). On peut par exemple utiliser le PID du processus (voir man getpid) qui varie suffisament pour rendre le jeu intéressant.

Attention ! N'utilisez pas cette technique dans la vraie vie si vous avez besoin de sécurité. La faible variation de la valeur initiale peut entraîner une faille de sécurité de type Insufficient Entropy.

(Bonus)

S'il n'y a aucune bombe autour de la position testée, seules les cases voisines sont automatiquement dévoilées. Modifiez ce cas pour que cela continue de révèler des cases jusqu'aux cases adjacentes à des bombes.

La méthode la plus directe est de rendre la fonction ecrire_info récursive.

Pour aller plus loin

Cet exercice est optionnel. Il permet aux étudiants débrouillards de s'exercer avec des questions plus difficiles. Cet exercice est volontairement peu guidé.

Le but de cet exercice est d'implémenter une IA capable de jouer (et de gagner !) seule.

Dans la fonction tour_de_jeu, supprimer les appels à scanf et remplacez-les par du code qui détermine le meilleur coup à jouer.
  • L'IA n'est bien sûr pas autorisée à consulter carte_solution !
  • Il est parfois nécessaire de choisir une case au hasard (notamment au premier tour). Dans ce genre de situation, vous pouvez sans doute calculer la probabilité qu'une bombe explose pour chaque case.
  • Si vous savez déja utiliser les pointeurs, le mieux est sans doute de définir une fonction void get_next_move(char* choix, int* i, int* j) et d'y placer le code de l'IA. Cette fonction remplit ses arguments avec les informations de l'action choisie par l'IA.