Dans le cadre du Challenge du mois Small Basic - Mars 2015 (en-US) , j’ai relevé le défi du challenge du jeu qui est de créer un jeu de Bataille Navale contre l’ordinateur.

Vous pouvez importer le programme dans Small Basic : PMD822-1. Ou télécharger le code source avec l'exécutable depuis la galerie TechNet : https://gallery.technet.microsoft.com/Jeu-Small-Basic-La-e64a160d.

Comme je fais ces programmes principalement pour aider les débutants en programmation dans leur apprentissage, j’ai essayé de faire un code clair et commenté. Toutefois ce programme est un peu complexe, aussi cet article donne plus de détail sur le code et son fonctionnement.

Le code du programme

Décomposition du code en sous-routine

Les sous-routines servent en général à exécuter un code que l'on réutilise un peu partout dans notre code, ceci afin d'éviter de faire des copier-coller. Dans ce programme le code a été découpé en de multiple sous-routines qui ne sont pas réutilisées, le but étant de créer des petites "unités" de code plus facile à lire et qui permet de se repérer rapidement dans le code (une recherche on on atteint un fonctionnement spécifique du programme).

C'est une pratique qu'il est intéressant d'avoir car elle permet d'organiser votre code et par conséquent vos idées.

Principes généraux du jeu

Le jeu commence par s’initialiser (appel de la sous-routine InitializeGame), ensuite une boucle s’exécute et appelle la sous-routine GameLoop qui exécute le code en fonction de l’état du jeu (voir plus bas), jusqu’à ce qu’une demande d’arrêt du jeu soit faite (gameState vide).

Les états du jeu

Le jeu peut être dans différents états, c’est la variable gameState qui contient l’état en cours :

  • intro : Affichage de l’écran d’introduction
  • createboard : Ecran permettant au joueur de créer son tableau avec ces bateaux
  • play : Gestion de la partie en cours
  • end : Gestion de l’écran qui affiche le gagnant de la partie
  • Vide : si gameState est vide alors on arrêt la boucle du jeu et on quitte le programme

Chaque état possède sa boucle "Game[State]Loop" qui est appelée par la sous-routine GameLoop en fonction de gameState. Cette boucle gère le fonctionnement de l’écran correspondant à l’état en cours.

Pour chaque état on trouve une sous-routine "Goto[State]Screen" qui se charge d’initialiser l’écran, certaines variables pour le fonctionnement de la boucle du jeu, et modifie gameState afin que le boucle du jeu exécute la bonne boucle d’état.

La gestion du clavier

La gestion du clavier ce fait simplement, un évènement HandleKeyPress est provoqué lorsqu’une touche du clavier est appuyée, cette touche est enregistrée dans la variable lastKey. Cette variable est utilisée par les boucles pour gérer le clavier. Généralement cette variable est effacée afin que la prochaine boucle ne gère pas une nouvelle fois cette touche.

La gestion de la souris

La souris est prise en charge dans ce jeu. Les mouvements de la souris ne sont pas utilisés, seule la position de la souris à un instant précis nous intéresse. En revanche nous gérons les clics de la souris sur le même principe que le clavier.

Un évènement HandleMouseDownUp est provoqué à chaque fois que l’un des boutons de la souris est appuyé ou relâché. La variable lastMouseButtons contient les boutons de la souris qui sont appuyés :

  • Left : Le bouton gauche est appuyé
  • Right : Le bouton droit est appuyé
  • Left+Right: Les deux boutons sont appuyés
  • Vide : aucun bouton n’est appuyé

Tout comme la gestion du clavier, cette variable est généralement effacée afin que les boutons ne soient pas traités plusieurs fois de suite.

Le tableau du jeu et les bateaux

Les bateaux sont positionnés dans un tableau généralement appelée champ de bataille. Ce tableau est une grille de 10 x 10 cellules. Il est représenté par un dessin avec des cellules de la taille de drawBoardCellSize pixels (en hauteur et largeur car ce sont des carrés).

Chaque bateau est un tableau Small Basic avec les index suivants :

  • ["name"] : Nom du bateau
  • ["x"] : Position X du bateau (commençant à 1)
  • ["y"] : Position Y du bateau (commençant à 1)
  • ["size"] : Indique la taille du bateau
  • ["dir"] : Indique la direction du bateau
    • "r" : Bateau horizontal - "r" comme Right (Vers la Droite en français)
    • "d" : Bateau vertical - "d" comme Down (Vers le Bas en français)
La liste des bateaux est enregistrée dans la variable ships pour les bateaux du joueur et computerShips pour les bateaux de l'ordinateur.

Les bateaux ne peuvent pas sortir du tableau, et ne peuvent pas se chevaucher ni se toucher.

La liste des bateaux utilisés (nom et taille) dans le jeu est construite dans la sous-routine BuildShips qui se trouve en fin du code source. Si vous voulez modifier les types de bateaux dans le jeu, c’est dans cette sous-routine qu’il faut le faire.

Initialisation du jeu

Au démarrage le jeu appelle la sous-routine InitializeGame qui prépare le jeu. Cette sous-routine ne fait rien d’extraordinaire, elle initialise des variables, prépare la fenêtre graphique, et branche les évènements clavier et souris.

On y trouve quelques variables de paramètrage du jeu comme drawBoardCellSize qui indique la largeur et hauteur d’une cellule du jeu.

Pour finir elle appelle GotoIntroScreen pour passer à l’état intro du jeu.

La boucle principale du jeu

Ce jeu passe la majeure partie de son temps à attendre une action du joueur. Cette action est détectée grâce aux variables lastKey et lastMouseButtons. Pour gérer celà on utilise une "boucle de jeu" qui est une boucle While qui à chaque itération test ces variables pour exécuter l'action joueur.

Comme notre programme peut se trouver dans différents états, les actions du joueur diffèrent en fonction de l'état, c'est pour celà que nous avons un traitement de boucle pour chaque état (voir plus haut).

Notre boucle principale (en début du code source) appelle GameLoop qui exécute le traitement spécifique en fonction de gameState puis fait une légère pause. Cette boucle s'exécute tant que gameState n'est pas vide (donc est dans un état du jeu). Pour arrêter le jeu il suffit de mettre la variable à vide et la boucle s'arrête.

Pourquoi faire une pause ?

Si vous retirez cette pause, et que vous exécutez le programme; en regardant les performances de votre ordinateur, vous constaterez qu'un de vos microprocesseur va fonctionner à 100% (ou presque). De manière générale c’est une situation qu'on cherche à éviter afin de perturber le moins possible les autres programmes qui s’exécutent sur votre ordinateur (logiciel de mail, messagerie instantanée, etc.).

En faisant une petite pause (de 50 ms dans notre cas) votre ordinateur va subitement arrêter de tourner à fond. Cette pause n’impact pas notre jeu (nous ne sommes pas assez rapide pour cela) et libère de la ressource pour les autres applications. C’est une bonne pratique à essayer d’appliquer le plus souvent possible.

L’écran d’introduction

C’est l’écran qui démarre le jeu et vous demande si vous voulez faire une nouvelle partie ou quitter le jeu.

Il est initialisé par GotoIntroScreen, qui va nettoyer la fenêtre graphique et dessiner les messages d’informations, puis changer l’état du jeu en intro.

La boucle est gérée par GameIntroLoop. Cette sous-routine ne fait pas grand-chose, si la touche "Echap" est appuyée alors l’état du jeu passe à 'Vide' provoquant l’arrêt du jeu. Sinon si on appuie sur la barre espace ou les touches "Retour", "Entrée" ou que l’on fait un clic souris, on démarre une nouvelle partie en passant à l’état createboard via la sous-routine GotoCreateboardScreen.

L’écran de création du tableau du joueur

Cet écran permet au joueur de placer ces bateaux dans son tableau (ou "Champ de Bataille") avant d’affronter l’ordinateur.

L’écran est initialisé par GotoCreateboardScreen qui va effacer la fenêtre graphique, afficher le titre, afficher une grille du jeu grâce à la sous-routine DrawBoard (voir plus loin les sous-routines utilitaires), construire la liste des bateaux grâce à BuildShips puis créer une forme (Shape) pour chaque bateau qu'il faudra placer sur le champ de bataille. Elle dessine les textes d’explication pour le joueur. Ensuite on passe à l’état createboard. Une utilise une forme pour chaque bateau car on peut les déplacer.

La boucle de gestion se trouve dans la sous-routine GameCreateboardLoop. Cette boucle gère plusieurs notions :

  • Le bateau en cours de placement grâce à la variable currentShip qui contient le numéro du bateau dans la liste du joueur ships
  • Le "curseur" qui est la position où l’on va placer le bateau. Ce curseur est géré par la variable currentCursor : un tableau avec un index ["x"] pour la position X et l’index ["y"] pour la position y. On affiche le bateau à l'endroit du curseur pour indiquer où on va le placer.

La souris

La souris peut être utilisée pour positionner le bateau, pour cela on calcul la position du pointeur de la souris par rapport à la position où est dessinée le tableau pour calculer la "cellule" qui se trouve sous la souris. Puis on déplace le curseur en fonction.

Pour calculer la position dans le tableau il faut convertir les "pixels" de la souris en "cellule" du champ de bataille. Voir plus loin dans "les techniques utilisées" pour des détails sur ce calcul.

Le clavier

Les flèches du clavier permettent de déplacer le curseur. En fonction de la touche on va déterminer si on peut se déplacer dans la direction demandée. En fonction de la direction on va empêcher le déplacement du bateau si on sort du tableau.

La rotation du bateau

Au-delà du déplacement, on peut également changer la direction du bateau (barre espace ou clic droit), dans ce cas on supprime la forme en cours pour la remplacer par une nouvelle forme avec de nouvelles dimensions (on ne peut pas modifier les dimensions d’une forme dans Small Basic). Pour finir on vérifie que le bateau reste dans le tableau, et on le déplace si nécessaire.

La pose d’un bateau

Lorsque la position d’un bateau convient au joueur il valide la position (touche "Entrée" ou clic gauche). Pour cela on fait appelle à la sous-routine ValidateShip. Cette dernière est expliquée en détails plus loin dans les sous-routines utilitaires. Si la position du bateau est valide, on l'enregistre dans la liste des bateaux ships et on passe au suivant. Lorsqu’on enregistre la position on va dessiner un rectangle pour montrer au joueur où il ne pourra plus placer d'autre bateau. Cette partie du code calcul le rectangle englobant le bateau, puis va le redimensionner pour qu'il ne sorte pas de la grille de dessin.

S’il tous les bateaux sont placés on passe à la génération du tableau de l’ordinateur grâce à la sous-routine GotoBuildComputerShipsScreen.

Ecran de génération du champ de bataille de l’ordinateur

Malgré son nom GotoBuildComputerShipScreen ne gère pas un état. La création du tableau de l’ordinateur ne nécessite pas une boucle de jeu car il n'y a d'action joueur.

Cet écran affiche un message pour faire patienter le joueur le temps de créer le tableau pour l’ordinateur.

La technique utilisée pour créer ce tableau est assez simpliste mais fonctionne plutôt bien. Pour chaque bateau à placer, on calcul une position aléatoire. Ensuite on utilise ValidateShip comme pour valider le placement d’un bateau du joueur (voir plus loin pour plus de détail sur cette sous-routine).

Si le bateau à une position valide, on l’enregistre dans le tableau des bateaux de l’ordinateur computerShips, sinon on relance une position aléatoire.

Potentiellement (si vous n’avez pas de chance avec les nombres aléatoires) cette technique peut prendre du temps pour finaliser le tableau. Dans les faits je ne suis pas parvenu à un temps de plus d’une seconde. Donc j’ai gardé cet algorithme simpliste.

Une fois le tableau de l’ordinateur généré on lance la partie avec la sous-routine GotoPlayScreen.

Déroulement de la partie

Initialisation

GotoPlayScreen initialise la partie en créant la variable scores qui est un tableau Small Basic qui va contenir le score de chaque participant de la partie.

Il va dessiner l’écran avec les titres, affichage des scores ainsi qu’une grille pour chaque champ de bataille : celle du joueur où s’affiche ces bateaux et les coups de l’ordinateur, et celle où le joueur indiquera ces coups et cherchera les bateaux de l’ordinateur. Puis création des bateaux du joueur (formes positionnées sur la grille).

On va créer également un "curseur" qui va permettre au joueur d’indiquer où sera son prochain coup.

Pour finir on tire au hasard le joueur qui va commencer à jouer.

On passe à l’état play.

Boucle de jeu

Dans la boucle GamePlayLoop, on détermine si c’est à l’ordinateur de jouer où au joueur. Pour celà on utilise la variable currentPlayer qui contient soit "player" soit "computer". Si c’est à l’ordinateur on appelle la sous-routine ComputeNextMove (voir plus loin pour les détails). Sinon on reste dans la boucle pour gérer les actions du joueur.

La gestion des actions du joueur fonctionne un peu comme lors de la création du tableau du joueur : on a un curseur (carré vert) qui est déplaçable par la souris ou le clavier, on utilise les mêmes principes qu'expliqués plus haut dans GameCreateboardLoop. En revanche la validation du coup n’est pas identique.

Lorsque le joueur valide, on détermine si ce coup a déjà été joué. Si c’est le cas, le coup n’est pas accepté, on attend un autre coup.

Si le coup est valide, on recherche si ça tombe sur un bateau de l’ordinateur, on dessine une information en fonction (un point si c'est un coup dans l'eau, un carré si c'est sur un bateau). On enregistre le coup (pour éviter de faire deux fois le même) dans la variable playerGameState["moves"]. Ensuite:

  • On calcule le score du joueur
  • On calcule le numéro du prochain tour
  • Si le joueur à gagné (trouvé tous les bateaux), on appelle GotoEndGame pour finir la partie
  • Si c'est un coup dans l'eau, c'est à l'ordinateur de jouer (on modifie la variable currentPlayer)
  • Si le coup est sur un bateau, le joueur continue de jouer

Calcul du prochain coup de l’ordinateur

Le calcul de son prochain coup se fait dans la sous-routine ComputeNextMove

Dans cette version, l’ordinateur n’est pas très intelligent ;) Il fait des coups au hasard. Pour déterminer son prochain coup, on recherche un coup non joué dans la liste des coups. Voir le détail dans les techniques utilisées.

En revanche il a un petit peu de jugeotte, s’il trouve un bateau il va "exclure" les prochains coups impossibles (les cellules au Nord-Ouest, Nord-Est, Sud-Est, Sud-Ouest) en les enregistrant comme s’il les avait joués et en les dessinant sur la grille du joueur.

Le reste fonctionne comme pour le joueur, on calcul les scores, et on détermine si il a gagné, ou qui est le prochain joueur.

Fin de partie

La fin de partie est gérée par GotoEndGame qui affiche les titres et détermine qui a gagné grâce aux valeurs dans la variable scores.

La boucle GameEndLoop elle ne fait rien qu’attendre qu’on appuie sur une touche pour revenir à l’écran d’introduction grâce à la sous-routine GotoIntroScreen.

Les sous-routines utilitaires

Certaines sous-routines servent à exécuter du code récurrent comme DrawTitle qui dessine le titre sur chaque écran, SetShipShapeProperties qui définie les propriétés graphiques pour dessiner les bateaux, ou encore UpdateScores qui actualise les textes qui affichent les scores.

En plus nous avons quelques sous-routines plus complexes.

DrawBoard

Cette sous-routine dessine la grille d’un champ de bataille. Comme on peut dessiner cette grille à deux endroits de l’écran (notamment dans l’écran de jeu, un pour le joueur l'autre pour l'ordinateur), avant d’appeler cette sous-routine il faut affecter la position de dessin dans les variables drawBoardX et drawBoardY pour indiquer à DrawBoard où dessiner la grille en pixels.

Le reste de la sous-routine est une boucle qui dessine les lignes de la grille et les en-têtes.

ValidateShip

Cette sous-routine est assez compliquée. Elle a pour fonction de déterminer si un bateau peut être positionné dans le tableau d’après une liste de bateau.

Elle détermine si le bateau ne sort pas du tableau, ne chevauche pas un autre bateau ni touche un autre bateau.

Pour fonctionner cette sous-routine a besoin de "paramètres", il s’agit de variables qu’il faut assigner avant d’appeler la sous-routine :

  • validShipList : tableau Small Basic contenant la liste des bateaux qu’il faut tester
  • validShip : Numéro du bateau dans la liste précédente
  • validPosX : Position X où on veut placer le bateau
  • validPosY : Position Y où on veut placer le bateau
  • validDir : Direction dans laquelle on veut orienter le bateau

La sous-routine va détermine la validité de cette position et affecter la variable shipIsValid pour indiquer son résultat :

  • True : La position est valide
  • OutOfBoard : La position indique un bateau qui sort du tableau
  • OnAnotherShip : La position indique un bateau qui touche ou chevauche un autre bateau

La première chose que fait cette sous-routine est de calculer les positions du début et de la fin du bateau (dans les variables sx1, sy1, sx2, sy2) pour obtenir un rectangle orienté selon la direction.

Une fois le rectangle défini on calcul si le bateau sort du tableau.

Sinon on boucle sur chaque bateau de la liste validShipList. Si le bateau est positionné et qu’il ne s’agit pas du bateau que l’on veut tester, on calcul également le "rectangle" du bateaux à tester (variables ix1, iy1, ix2, iy2).

Ensuite le but est de calculer si les deux bateaux se croisent (intersection de rectangles). Ces tests se font généralement en 6 ou 8 conditions de mémoire (pour tester tous les cas possibles). Hors il s’avère que si on teste l’inverse (que les deux rectangles NE SE CROISENT PAS) on peut faire le test en seulement 4 conditions. C’est ce que nous faisons. Petite particularité de ces tests, nous avons des offsets ("-1"/"+1") dans les calculs, c'est pour prendre en compte que les bateaux ne doivent pas se toucher, donc avec ces offsets nous faisons comme si notre bateau testé était plus épais d'une cellule tout autour de lui. J'aurais pu calculer son rectangle directement, mais j'avoues que je n'y ai pas pensé sur le moment, et maintenant que le code est publié je laisse ainsi ;)

Attention : la plupart du temps j’utilise les variables i et j pour les boucles For (c'est une tradition des programmeurs), exceptionnellement j’ai utilisé la variable vi dans la boucle For de cette sous-routine car elle est appelée par GotoBuildComputerShipsScreen depuis une boucle For utilisant i, donc réutiliser i provoquerait un bug. On utilise donc une variable qui n’est pas utilisée ailleurs.

Techniques utilisées

Convertir les pixels souris en cellule de champ de bataille

Notre champ de bataille est représenté par une grille dessinée dans la fenêtre. Le but de cet algorithme est de trouver la cellule du champ qui se trouve sous le curseur de la souris.

La première chose à faire est de calculer où on se trouve dans la grille en pixel. Si ma grille est dessinée à la position [100;80], et que ma souris est en [120;97], alors ma souris se trouve à la position [20;17] par rapport au coin supérieur gauche de la grille. On obtiens cette position en soustrayant la position X de la grille à la position X de la souris par rapport à la fenêtre graphique (GraphicsWindow.MouseX), et on fait la même opération pour la position Y (GraphicsWindow.MouseY).

Une fois que l'on a la position de la souris "relative" à la grille, pour connaître la cellule dans laquelle on se trouve, il suffit de diviser la position X par la largeur de la cellule (on arrondi à l'entier inférieur) pour obtenir la colonne, et la position Y par la hauteur de la cellule (également arrondi à l'entier inférieur) pour obtenir la ligne.
En effet si j'ai une cellule large de 20 pixels:

  • Souris à 17 => (17/20) = 0,85 => Math.Floor(0,85) = colonne 0
  • Souris à 40 => (40/20) = 2,00 => Math.Floor(2,00) = colonne 2

Dans notre cas la largeur et la hauteur d'une cellule est identique et définie dans la variable drawBoardCellSize. Notre calcul nous obtient une valeur commençant par 0. Mais dans notre grille nous avons des en-tête contenant les numéros de ligne et les lettres de colonne à cette position, par conséquent la position des bateaux commencent bien à 1 ce qui correspond à ce que nous avons dans nos listes, nous n'avons pas besoin de faire des calculs supplémentaires.

Pour résumé si le champ de bataille est dessiné à partir de la position [100;80] et que drawBoardCellSize est égal à 10 pixels, si ma souris se trouve en [120;97] :

  • Pour X :
    • (120 – 100) => 20
    • 20 / drawBoardCellSize => 2
    • Math.Foor(2) => 2
  • Pour Y :
    • (97 – 80) => 17
    • 17 / drawBoardCellSize => 1,7
    • Math.Foor(1,7) => 1

La cellule correspondant à ma position de souris est [2;1].

Recherche aléatoire d'un coup pour l'ordinateur

Lorsque l'ordinateur cherche son prochain coup dans ComputeNextMove, on le fait de manière aléatoire, toutefois il ne faut pas que l'on tombe sur un coup déjà effectué.

Pour celà la technique utilisé est simple, on détermine le nombre de coup qui sont possibles, c'est à dire le nombre de cellules qui n'ont pas encore été découvertes. Ce nombre est égale au nombre de cellules du champ de bataille (10x10) moins le nombre de coups déjà effectués. On demande un nombre aléatoire compris entre 1 et le nombre de coups possible. Dans notre cas celà donne :

    pos = Math.GetRandomNumber(100 - Array.GetItemCount(computerGameState["moves"])) - 1

Ensuite on parcours toutes les cellules du champ de bataille et on décrémente (retire 1) le nombre aléatoire (pos dans notre cas) à chaque fois que l'on trouve une cellule qui n'a pas été découverte. Dés que pos atteint 0 alors on a trouvé notre cellule.

Le point particulier dans l'algorithme sont ces deux lignes :

        move["x"] = 1 + Math.Remainder(i, 10)
        move["y"] = 1 + Math.Floor(i / 10)
    
Elles permettent de convertir une position linéaire, en position [X;Y] (ou [colonne;ligne] si vous préférez) dans une grille. Dans une grille de 10x10 :
  • Position 0 => [0;0]
  • Position 5 => [5;0]
  • Position 10 => [0;1]
  • Position 37 => [7;3]
une position linéaire n'est ni plus ni moins qu'une vision de la grille applatie en mettant bout à bout chaque ligne. Pour obtenir la ligne correspondante il faut diviser la position par la largeur de la grille, le tout arrondi à l'entier inférieur. Pour obtenir la colonne correspondante il suffit de récupérer le restant de la division de la position par la largeur de la grille. C'est ce que font ces deux lignes. Nous ajoutons 1 à ces éléments car notre champs de bataille commence à 1 (et pas 0).

Participez

J'ai essayé d'être précis dans mes explications, toutefois si ce n'est pas le cas, n'hésitez pas à laisser des commentaires ou même à corriger cet article, le Wiki est fait pour celà.

Si vous voulez discuter plus précisément de ce programme je vous recommande de poster des messages dans le forum Small Basic plutôt que les commentaires d'articles qui servent plus pour demander ou ajouter des précisions sur l'article en lui-même. La discussion sur le forum qui concerne ce programme : https://social.msdn.microsoft.com/Forums/en-US/1ab990d7-c6c3-49d6-abba-693571262afa/my-new-game-battleship-la-bataille-navale-frfr?forum=smallbasic .

Autres langues