Skip to content

Bataille navale POO

Nous allons retravailler la bataille navale développée de façon procédurale pour en faire un programme orienté objet.


Un diagramme de classe de la Bataille Navale

Acquis attendus

Objectifs

Dans cette bataille navale, nous allons gérer des Navires de différentes tailles et positions dans une matrice symétrique de 0 par 9 de côté.

  0 1 2 3 4 5 6 7 8 9
0 . . . . . . . . . .
1 . . . . . . . . . .
2 . . . . . . . . . .
3 . . . . . . . X . .
4 . . . . . . . . . .
5 . . . . . . . . . .
6 . . . . . . . . . .
7 . . . . . . . . . .
8 . . . . . . . . . .
9 . . . . . . . . . .

Dans notre programme, toutes les coordonnées sont gérés sous la forme d'un tuple au format (y,x) car pour une liste 2D, le premier indice correspond aux lignes, et le second indice aux colonnes.

Dans la matrice ci-dessus, le X se situe donc aux coordonées (3,7).

Fait particulier de notre programme, et contrairement à la version sans POO, nous n'allons pas utiliser de matrice pour gérer le jeu. Ce sont les Navires qui seront en charge de leur coordonnées initiale ainsi que designe (y,x) désignant leur structure.

Nous ferons usage d'une liste 2D seulement pour afficher les informations au joueur.

Pour cette exercice nous allons nous appuyer sur trois type de structures de données :

  • des tuples pour les positions (ie: (2,7) pour la ligne 2, la colonne 7)
  • des dictionnaires pour stocker les positions et l'état des positions correspondant à la structure (ie {(2,7):True})
  • les classes et les objets qui en découlent : Ship, Player, Game

The Ship Class

C'est l'unité la plus fine de notre jeu, c'est pourquoi nous commençons par la décrire pour voir quels attributs et quelles capacités nous voulons lui donner.

Notre navire de classe Ship doit pouvoir rendre compte de sa santé précisément, et pour cela il y a plusieurs possibilités.

  • La première c'est de répercuter la structure de chaque bateau dans une matrice. C'est plus ou moins ce que l'on a fait dans l'exercice précédent.
  • La deuxième c'est de gérer les coordonnées associées à chaque case directement dans les Navires, c'est cette version que nous allons implémenté.
class Ship:
    def __init__(self,name, longueur, direction, pos):
        self.name = name
        self.longueur = longueur
        self.direction = direction
        self.pos = pos
        self.structure = self.buildStructure(pos, longueur, direction)
        self.alive = True
  • La taille en nombre de cases représente la vie de ce Navire, par exemple un Croiseur de 4 cases aura 4 vie avant d'être coulé. Les vies sont réparties de façon homogène sur chaque Navire, une case = une vie. Nous allons stocker ces valeurs au sein d'un attribut structure
  • Les cases du Navire se répartissent sur la grille via un attribut direction qui prennent soit la valeur horizontale ou verticale
  • La tête du bateau designe la première case, celle-ci est stockée dans un tuple (y,x). Cette information est nécessaire pour positionner le bateau. Avec les informations de direction et de longueur on est capable de reconstruire assez facilement la forme du bateau sur la grille.

Un tuple est une structure de donnée qui contient 2 valeurs. On accède à ces valeurs en utilisant la même notation que les listes. Par exemple pour l'attribut pos de notre classe Ship :

self.pos[0] # renvoie la valeur ligne / y
self.pos[1] # renvoie la valeur colonne / x

Nous allons utiliser un système simple basé sur un dictionnaire qui nous permettra de savoir rapidement si un bateau est endommagé ou pas.

Par exemple, un croiseur avec une longueur de 4, de position initiale (3,2), de direction verticale et en bon été sera défini par la structure suivante :

{(3,2):True, (4,2):True, (5,2):True, (6,2):True}

Cette structure de donnée clef:valeur nous permet d'associer deux informations ensemble en se basant sur une clef unique. Dans notre cas, nous allons associer chaque tuple coordonnée avec une valeur True or False indiquant si la structure du Navire est endommagé ou pas à cette position.

Un exemple simple, avec un Sous-Marin de 3 cases, vertical, démarrant à la position (2,4), l'appel à un dictionnaire se fait ainsi :

ssmarin={(2,4):True, (2,5):True, (2,6):True}
ssmarin[(2,4)] # => renvoie True 
ssmarin[(2,7)] # => renvoie une erreur

Autre intérêt du dictionnaire c'est qu'il est facile en utilisant du mot clef in de savoir si un tuple position est contenue ou pas dans cette structure.

(2,4) in ssmarin #=> renvoie True
(2,7) in ssmarin #=> renvoie False
  • Pour récupérer les clefs d'un dictionnaire : ssmarin.keys()
  • Pour récupérer les valeurs d'un dictionnaire : ssmarin.values()
  • Pour récupérer les clefs et les valeurs d'un dictionnaire : ssmarin.items()
  • Pour parcourir un dictionnaire en ayant accès aux clef et aux valeurs :
    for k,v in ssmarin.items():
        print ("clef : ", k, " and value : ", v)
    

Enfin, dernière astuce, il est possible d'utiliser la fonction sum() sur des structures de données contenant des Booléen : sum(ssmarin.values()) renvoie 3 car True est présent 3 fois.

Sachant tout cela on peut construire plusieurs fonctions dans Ship qui nous renseigne :

  • sur l'état global de Ship : combien de structure valide me reste-t-il ?
  • sur l'état détaillé du Ship : quel partie est touché ? quel partie est ok ?
  • sur du calcul d'intersection avec une autre structure de navire, nécessaire pour éviter les collisions lors du placement.

La première getStructureLife() retourne le nombre de cases valides restantes :

  def getStructureLife(self):
        life = sum(self.structure.values())
        print(life)
        return life

La deuxième getStructureByType renvoie une liste contenant deux listes de positions : la première contient les tuples positions touchées, la deuxième contient les tuples positions encore valide.

 def getStructureByType(self):
        structWithDamage = []
        structValid = []

        for key, value in self.structure.items():
            if value == False: structWithDamage.append(key)
            else: structValid.append(key)
        return [structWithDamage,structValid] 

Pour gérer les collisions au niveau du placement des bateaux, et il y'en aura, on peut demander à une instance de Ship si une autre structure de bateau {(y,x), ...} passé en paramètre recoupe ou pas les coordonnées existantes du bateau.

Pour cela, on peux exploiter des fonctions bien pratique d'union, d'intersection applicable sur les dictionnaires.

Pour calculer l'intersection entre deux dictionnaires, on compare les clefs en utilisant la notation & .

Si l'intersection entre la structure de notre navire et celle passée en paramètre renvoie une valeur, c'est que cette position est partagée, et donc qu'elle n'est pas utilisable !

def posExistAt(self, structureToTest):
        if structureToTest.keys() & self.structure.keys() :
            return True
        else:
            return False

La fonction buildStructure est la plus complexe de notre classe Ship car c'est elle qui construit notre tableau de coordonnées propre à la structure. Le code n'est pourtant pas si différent de la version de la bataille navale sans POO, nous l'avons juste transformé pour un usage avec notre nouveau système de coordonées utilisant des tuples et des dictionnaires.

Chaque Ship se voit donc attribuer un dictionnaire contenant autant d'éléments que le paramètre longueur. Le départ de notre calcul est donné par le paramètre position, et enfin la direction nous dit si il s'agit d'incrémenter la structure en se déplacant sur l'axe x ou l'axe y.

def buildStructure(self, pos, longueur, direction):
        structure = {}
        if direction == "v":
            xPos= pos[1]
            toYPos=pos[0] + longueur
            for y in range(pos[0], toYPos):
                structure[(y,xPos)]=True
        else:
            yPos=pos[0]
            toXPos=pos[1] + longueur
            for x in range(pos[1], toXPos):
                structure[(yPos,x)]=True
        print(structure)
        return structure

Enfin, il nous faut une fonction destroy qui prend une position en paramètre. En fonction du nombre de structure encore valide (getStructureLife()) sur ce bateau, elle détermine aussi si le bateau est fonctionnel, ou si c'est devenu une épave inactive (self.alive).

    def destroy(self,pos):
        self.structure[pos] = False
        if self.getStructureLife() == 0:
            self.alive = False

The Player class

class Player:
    def __init__(self, name, typeOf):
        self.name = name
        self.typeOf = typeOf
        self.missedCoordinates = []
        self.ship = []

Attributs

La classe Player va porter la responsabilité de sa flotte de Navire qui sera stockée dans un attribut self.ship

Nous savons qu'il y aura un joueur ordinateur, et un joueur humain, nous en profitons pour stocker cette différence à l'initialisation dans l'attribut self.typeof

Chaque joueur maintient également les positions (y,x) qu'il a déjà joué dans une liste de position self.missedCoordinates

Fonctions

On imagine les signatures suivantes pour nos fonctions

    def selectCoordinate(self):
        pass

    def selectDirection(self):
        pass

    def addAShip(self, shipObject):
        pass

    def isThereOverlapAt(self, navireToTest):
        pass

    def isThereHitAt(self, pos):
        pass

    def getLifeOfShips(self):
        pass

    def isThereShipAlive(self):
        pass

Les deux premières fonctions sont assez explicites, elle renvoie des coordonnées et une direction en fonction du type de joueur : - si self.typeOf == "human" alors ces coordonnées sont saisies par l'utilisateur - si self.typeOf == "AI" alors ces coordonnées et la direction sont calculées de façon random.

Ces fonctions ne teste ni de la validité de la position ou des coordonnées saisies, c'est une tâche que l'on va pour le moment délèguer à notre future classe Game dans la fonction d'initialisation des joueurs et de leur flottes de Navire playerInit(self, name, typeOf). C'est un choix, mais il pourrait en être autrement.

La fonction addAShip ajoute un objet de classe Ship à sa liste existante.

La fonction isThereOverlapAt(self,navireToTest) détermine si oui ou non il y existe une intersection entre la structure d'un objet Ship passé en paramètre (navireToTest) et la structure d'un des bateaux dans notre flotte. Si c'est le cas alors la fonction renvoie True

La fonction isThereHitAt(self,pos) prend le paramètre pos qui représente une cible à toucher dans notre flotte. Si une structure est concernée par cette position, et qu'elle n'a pas déjà été touché, alors on applique la fonction de détruction (destroy(pos)) sur ce navire à la position donnée. On renvoie l'objet Ship concerné par la destruction.

La fonction getLifeOfShips(self) renvoie un dictionnaire qui contient pour chaque bateau la liste des position / structures encore valide.

La fonction isThereShipAlive(self) parcourt la flotte pour voir si il reste encore un bateau dont l'attribut alive est à True. Si ce n'est pas le cas alors elle renvoie False, sinon True

The Game class

La classe Game intègre la logique de jeu ou les matrice des joueurs, avec la logique associée.

class Game:
    def __init__(self,size):
        self.size = size
        self.players = []
        self.typeOfShip = {"Croiseur": 4, "Porte-Avion": 5, "Sous-Marin": 3, "Torpilleur": 1, "Contre-Torpilleur":3}
        self.symbol = {"ship_sink":"x", "miss":"o", "other":"~" }
        self.symbolOfShip = {"Croiseur": "C", "Porte-Avion": "P", "Sous-Marin": "U", "Torpilleur": "V", "Contre-Torpilleur":"T"}

Les signatures de fonctions

def playerInit(self, name, typeOf):
    pass

def run(self):
    pass

def buildBaseDisplay(self):
    pass

def prettyDisplay(self, matrix):
    pass

def displayMap(self, player, layersToHide):
    pass

def isPosIntoWorld(self, pos, shipSize, direction):
    pass

La fonction playerInit() met en place tout les éléments du jeu avant son lancement :

  • création et stockage des joueurs Player
  • création, placement (et vérification) des positions de bateaux Ship par les joueurs

La fonction run() contient la boucle de jeu principale, on organise l'alternance des combats en s'appuyant sur un dictionnaire définis ainsi :

nextBattle={self.players[0]:self.players[1], self.players[1]:self.players[0]}

Les 3 fonctions suivantes (buildBaseDisplay, prettyDisplay, displayMap) sont utilisé pour calculer la matrice d'affichage qui sera affiché pour chaque joueur à chaque tour.

Enfin la fonction isPosIntoWorld teste si un bateau qui est dans une position initiale pos, de longueur shipSize et de direction direction dépasse ou pas de nombre univers de jeu. Si c'est le cas alors cette fonction renvoie faux.