Programmation Python/Analyse de programmes concrets

Dans ce chapitre, nous allons nous efforcer d'illustrer la démarche de conception d'un programme graphique, depuis ses premières ébauches jusqu'à un stade de développement relativement avancé. Nous souhaitons montrer ainsi combien la programmation orientée objet peut faciliter et surtout sécuriser la stratégie de développement incrémental que nous préconisons.

L'utilisation de classes s'impose, lorsque l'on constate qu'un projet en cours de réalisation se révèle nettement plus complexe que ce que l'on avait imaginé au départ. Vous vivrez certainement vous-même des cheminements similaires à celui que nous décrivons ci-dessous.

Jeu des bombardes

modifier

Ce projet de jeu[1] s'inspire d'un travail similaire réalisé par des élèves de terminale.

Il est vivement recommandé de commencer l'ébauche d'un tel projet par une série de petits dessins et de schémas, dans lesquels seront décrits les différents éléments graphiques à construire, ainsi qu'un maximum de cas d'utilisations. Si vous rechignez à utiliser pour cela la bonne vieille technologie papier/crayon (laquelle a pourtant bien fait ses preuves), vous pouvez tirer profit d'un logiciel de dessin technique, tel l'utilitaire Draw de la suite bureautique OpenOffice.org[2].

L'idée de départ est simple : deux joueurs s'affrontent au canon. Chacun doit ajuster son angle de tir pour tâcher d'atteindre son adversaire, les obus décrivant une trajectoire balistique.

L'emplacement des canons est défini au début du jeu de manière aléatoire (tout au moins en hauteur). Après chaque tir, les canons se déplacent (afin d'accroître l'intérêt du jeu, l'ajustement des tirs étant ainsi rendu plus difficile). Les coups au but sont comptabilisés.

Le dessin préliminaire que nous avons reproduit à la page précédente est l'une des formes que peut prendre votre travail d'analyse. Avant de commencer le développement d'un projet de programmation, il vous faut en effet toujours vous efforcer d'établir un cahier des charges détaillé. Cette étude préalable est très importante. La plupart des débutants commencent bien trop vite à écrire de nombreuses lignes de code au départ d'une vague idée, en négligeant de rechercher la structure d'ensemble. Leur programmation risque alors de devenir chaotique, parce qu'ils devront de toute façon mettre en place cette structure tôt ou tard. Ils s'apercevront alors bien souvent qu'il leur faut supprimer et ré-écrire des pans entiers d'un projet qu'ils ont conçu d'une manière trop monolithique et/ou mal paramétrée.

  • Trop monolithique : cela signifie que l'on a négligé de décomposer un problème complexe en plusieurs sous-problèmes plus simples. Par exemple, on a imbriqué plusieurs niveaux successifs d'instructions composées, au lieu de faire appel à des fonctions ou à des classes.
  • Mal paramétrée : cela signifie que l'on a traité seulement un cas particulier, au lieu d'envisager le cas général. Par exemple, on a donné à un objet graphique des dimensions fixes, au lieu de prévoir des variables pour permettre son redimensionnement.

Vous devez donc toujours commencer le développement d'un projet par une phase d'analyse aussi fouillée que possible, et concrétiser le résultat de cette analyse dans un ensemble de documents (schémas, plans, descriptions...) qui constitueront le cahier des charges. Pour les projets de grande envergure, il existe d'ailleurs des méthodes d'analyse très élaborées (UML, Merise...) que nous ne pouvons nous permettre de décrire ici car elles font l'objet de livres entiers.

Cela étant dit, il faut malheureusement admettre qu'il est très difficile (et même probablement impossible) de réaliser dès le départ l'analyse tout à fait complète d'un projet de programmation. C'est seulement lorsqu'il commence à fonctionner véritablement qu'un programme révèle ses faiblesses. On constate alors qu'il reste des cas d'utilisation ou des contraintes qui n'avaient pas été prévues au départ. D'autre part, un projet logiciel est pratiquement toujours destiné à évoluer : il vous arrivera fréquemment de devoir modifier le cahier des charges au cours du développement lui-même, pas nécessairement parce que l'analyse initiale a été mal faite, mais tout simplement parce que l'on souhaite encore ajouter des fonctionnalités supplémentaires.

En conclusion, tâchez de toujours aborder un nouveau projet de programmation en respectant les deux consignes suivantes :

  • Décrivez votre projet en profondeur avant de commencer la rédaction des premières lignes de code, en vous efforçant de mettre en évidence les composants principaux et les relations qui les lient (pensez notamment à décrire les différents cas d'utilisation de votre programme).
  • Lorsque vous commencerez sa réalisation effective, évitez de vous laisser entraîner à rédiger de trop grands blocs d'instructions. Veillez au contraire à découper votre application en un certain nombre de composants paramétrables bien encapsulés, de telle manière que vous puissiez aisément modifier l'un ou l'autre d'entre eux sans compromettre le fonctionnement des autres, et peut-être même les réutiliser dans différents contextes si le besoin s'en fait sentir.

C'est pour satisfaire cette exigence que la programmation orientée objets est a été inventée.

Considérons par exemple l'ébauche dessinée à la page précédente.

L'apprenti programmeur sera peut-être tenté de commencer la réalisation de ce jeu en n'utilisant que la programmation procédurale seule (c'est-à-dire en omettant de définir de nouvelles classes). C'est d'ailleurs ainsi que nous avons procédé nous-même lors de notre première approche des interfaces graphiques, tout au long du chapitre 8. Cette façon de procéder ne se justifie cependant que pour de tout petits programmes (des exercices ou des tests préliminaires). Lorsque l'on s'attaque à un projet d'une certaine importance, la complexité des problèmes qui se présentent se révèle rapidement trop importante, et il devient alors indispensable de fragmenter et de compartimenter.

L'outil logiciel qui va permettre cette fragmentation est la classe.

Nous pouvons peut-être mieux comprendre son utilité en nous aidant d'une analogie :

Tous les appareils électroniques sont constitués d'un petit nombre de composants de base, à savoir des transistors, des diodes, des résistances, des condensateurs, etc. Les premiers ordinateurs ont été construits directement à partir de ces composants. Ils étaient volumineux, très chers, et pourtant ils n'avaient que très peu de fonctionnalités et tombaient fréquemment en panne.

On a alors développé différentes techniques pour encapsuler dans un même boîtier un certain nombre de composants électroniques de base. Pour utiliser ces nouveaux circuits intégrés, il n'était plus nécessaire de connaître leur contenu exact : seule importait leur fonction globale. Les premières fonctions intégrées étaient encore relativement simples : c'étaient par exemple des portes logiques, des bascules, etc. En combinant ces circuits entre eux, on obtenait des caractéristiques plus élaborées, telles que des registres ou des décodeurs, qui purent à leur tour être intégrés, et ainsi de suite, jusqu'aux microprocesseurs actuels. Ceux-ci contiennent dorénavant plusieurs millions de composants, et pourtant leur fiabilité reste extrêmement élevée.

En conséquence, pour l'électronicien moderne qui veut construire par exemple un compteur binaire (circuit qui nécessite un certain nombre de bascules), il est évidemment bien plus simple, plus rapide et plus sûr de se servir de bascules intégrées, plutôt que de s'échiner à combiner sans erreur plusieurs centaines de transistors et de résistances.

D'une manière analogue, le programmeur moderne que vous êtes peut bénéficier du travail accumulé par ses prédécesseurs en utilisant la fonctionnalité intégrée dans les nombreuses bibliothèques de classes déjà disponibles pour Python. Mieux encore, il peut aisément créer lui-même de nouvelles classes pour encapsuler les principaux composants de son application, particulièrement ceux qui y apparaissent en plusieurs exemplaires. Procéder ainsi est plus simple, plus rapide et plus sûr que de multiplier les blocs d'instructions similaires dans un corps de programme monolithique, de plus en plus volumineux et de moins en moins compréhensible.

Examinons par exemple notre ébauche dessinée. Les composants les plus importants de ce jeu sont bien évidemment les petits canons, qu'il faudra pouvoir dessiner à différents emplacements et dans différentes orientations, et dont il nous faudra au moins deux exemplaires.

Plutôt que de les dessiner morceau par morceau dans le canevas au fur et à mesure du déroulement du jeu, nous avons intérêt à les considérer comme des objets logiciels à part entière, dotés de plusieurs propriétés ainsi que d'un certain comportement (ce que nous voulons exprimer par là est le fait qu'il devront être dotés de divers mécanismes, que nous pourrons activer par programme à l'aide de méthodes particulières). Il est donc certainement judicieux de leur consacrer une classe spécifique.

Prototypage d'une classe « Canon »

modifier

En définissant une telle classe, nous gagnons sur plusieurs tableaux. Non seulement nous rassemblons ainsi tout le code correspondant au dessin et au fonctionnement du canon dans une même « capsule », bien à l'écart du reste du programme, mais de surcroît nous nous donnons la possibilité d'instancier aisément un nombre quelconque de ces canons dans le jeu, ce qui nous ouvre des perspectives de développements ultérieurs.

Lorsqu'une première implémentation de la classe Canon() aura été construite et testée, il sera également possible de la perfectionner en la dotant de caractéristiques supplémentaires, sans modifier (ou très peu) son interface, c'est-à-dire en quelque sorte son « mode d'emploi » : à savoir les instructions nécessaires pour l'instancier et l'utiliser dans des applications diverses.

Entrons à présent dans le vif du sujet.

Le dessin de notre canon peut être simplifié à l'extrême. Nous avons estimé qu'il pouvait se résumer à un cercle combiné avec un rectangle, celui-ci pouvant d'ailleurs être lui-même considéré comme un simple segment de ligne droite particulièrement épais.

Si l'ensemble est rempli d'une couleur uniforme (en noir, par exemple), nous obtiendrons ainsi une sorte de petite bombarde suffisamment crédible.

Dans la suite du raisonnement, nous admettrons que la position du canon est en fait la position du centre du cercle (coordonnées x et y dans le dessin ci-contre). Ce point clé indique également l'axe de rotation de la buse du canon, ainsi que l'une des extrémités de la ligne épaisse qui représentera cette buse.

 

Pour terminer notre dessin, il nous restera alors à déterminer les coordonnées de l'autre extrémité de cette ligne. Ces coordonnées peuvent être calculées sans grande difficulté, à la condition de nous remémorer deux concepts fondamentaux de la trigonométrie (le sinus et le cosinus) que vous devez certainement bien connaître :

Dans un triangle rectangle, le rapport entre le côté opposé à un angle et l'hypoténuse du triangle est une propriété spécifique de cet angle qu'on appelle sinus de l'angle. Le cosinus du même angle est le rapport entre le côté adjacent à l'angle et l'hypoténuse.

 

Ainsi, dans le schéma ci-contre : sinα = a/h et cosα = b/h.

Pour représenter la buse de notre canon, en supposant que nous connaissions sa longueur l et l'angle de tir α , il nous faut donc tracer un segment de ligne droite épaisse, à partir des coordonnées du centre du cercle (x et y), jusqu'à un autre point situé plus à droite et plus haut, l'écart horizontal Δx étant égal à l.cos α , et l'écart vertical Δy étant égal à l.sin α.

En résumant tout ce qui précède, dessiner un canon au point x, y consistera simplement à :

  • tracer un cercle noir centré sur x, y ;
  • tracer une ligne noire épaisse depuis le point x, y jusqu'au point x + l.cos α, y + l.sin α.

Nous pouvons à présent commencer à envisager une ébauche de programmation correspondant à une classe « Canon ». Il n'est pas encore question ici de programmer le jeu proprement dit. Nous voulons seulement vérifier si l'analyse que nous avons faite jusqu'à présent « tient la route », en réalisant un premier prototype fonctionnel.

Un prototype est un petit programme destiné à expérimenter une idée, que l'on se propose d'intégrer ensuite dans une application plus vaste. Du fait de sa simplicité et de sa concision, Python se prête fort bien à l'élaboration de prototypes, et de nombreux programmeurs l'utilisent pour mettre au point divers composants logiciels qu'ils reprogrammeront éventuellement ensuite dans d'autres langages plus « lourds », tels que le C par exemple.

Dans notre premier prototype, la classe Canon() ne comporte que deux méthodes : un constructeur qui crée les éléments de base du dessin, et une méthode permettant de modifier celui-ci à volonté pour ajuster l'angle de tir (l'inclinaison de la buse). Comme nous l'avons souvent fait dans d'autres exemples, nous inclurons quelques lignes de code à la fin du script afin de pouvoir tester la classe tout de suite :

from Tkinter import *
from math import pi, sin, cos

class Canon:
    """Petit canon graphique"""
    def __init__(self, boss, x, y):
        self.boss = boss            # référence du canevas
        self.x1, self.y1 = x, y     # axe de rotation du canon
        # dessiner la buse du canon, à l'horizontale pour commencer :
        self.lbu = 50               # longueur de la buse
        self.x2, self.y2 = x + self.lbu, y
        self.buse = boss.create_line(self.x1, self.y1, self.x2, self.y2,
                                     width =10)
        # dessiner ensuite le corps du canon par-dessus :
        r = 15                      # rayon du cercle
        boss.create_oval(x-r, y-r, x+r, y+r, fill='blue', width =3)

    def orienter(self, angle):
        "choisir l'angle de tir du canon"
        # rem : le paramètre <angle> est reçu en tant que chaîne de car.
        # il faut le traduire en nombre réel, puis convertir en radians :
        self.angle = float(angle)*2*pi/360
        self.x2 = self.x1 + self.lbu*cos(self.angle)
        self.y2 = self.y1 - self.lbu*sin(self.angle)
        self.boss.coords(self.buse, self.x1, self.y1, self.x2, self.y2)

if __name__ == '__main__':
    # Code pour tester sommairement la classe Canon :
    f = Tk()
    can = Canvas(f,width =250, height =250, bg ='ivory')
    can.pack(padx =10, pady =10)
    c1 = Canon(can, 50, 200)

    s1 =Scale(f, label='hausse', from_=90, to=0, command=c1.orienter)
    s1.pack(side=LEFT, pady =5, padx =20)
    s1.set(25)                          # angle de tir initial

    f.mainloop()
Commentaires
  • Ligne 6 : Dans la liste des paramètres qui devront être transmis au constructeur lors de l'instanciation, nous prévoyons les coordonnées x et y, qui indiqueront l'emplacement du canon dans le canevas, mais également une référence au canevas lui-même (la variable boss). Cette référence est indispensable : elle sera utilisée pour invoquer les méthodes du canevas. Nous pourrions inclure aussi un paramètre pour choisir un angle de tir initial, mais puisque nous avons l'intention d'implémenter une méthode spécifique pour régler cette orientation, il sera plus judicieux de faire appel à celle-ci au moment voulu.
  • Lignes 7 et 8 : Ces références seront utilisées un peu partout dans les différentes méthodes que nous allons développer dans la classe. Il faut donc en faire des attributs d'instance.
  • Lignes 9 à 16 : Nous dessinons la buse d'abord, et le corps du canon ensuite. Ainsi une partie de la buse reste cachée. Cela nous permet de colorer éventuellement le corps du canon.
  • Lignes 18 à 25 : Cette méthode sera invoquée avec un argument « angle », lequel sera fourni en degrés (comptés à partir de l'horizontale). S'il est produit à l'aide d'un widget tel que Entry ou Scale, il sera transmis sous la forme d'une chaîne de caractères, et nous devrons donc le convertir d'abord en nombre réel avant de l'utiliser dans nos calculs (ceux-ci ont été décrits à la page précédente).
  • Lignes 27 à 38 : Pour tester notre nouvelle classe, nous ferons usage d'un widget Scale. Pour définir la position initiale de son curseur, et donc fixer l'angle de hausse initial du canon, nous devons faire appel à sa méthode set() (ligne 36).

Ajout de méthodes au prototype

modifier

Notre prototype est fonctionnel, mais beaucoup trop rudimentaire. Nous devons à présent le perfectionner pour lui ajouter la capacité de tirer des obus.

Ceux-ci seront traités plutôt comme des « boulets » : ce seront de simples petits cercles que nous ferons partir de la bouche du canon avec une vitesse initiale d'orientation identique à celle de sa buse. Pour leur faire suivre une trajectoire réaliste, nous devons à présent nous replonger dans notre cours de physique :

 

Comment un objet laissé à lui-même évolue-t-il dans l'espace, si l'on néglige les phénomènes secondaires tels que la résistance de l'air ?

Ce problème peut vous paraître complexe, mais en réalité sa résolution est très simple : il vous suffit d'admettre que le boulet se déplace à la fois horizontalement et verticalement, et que ces deux mouvements simultanés sont tout à fait indépendants l'un de l'autre.

Vous allez donc établir une boucle d'animation dans laquelle vous recalculez les nouvelles coordonnées x et y du boulet à intervalles de temps réguliers, en sachant que :

  • Le mouvement horizontal est uniforme. À chaque itération, il vous suffit d'augmenter graduellement la coordonnée x du boulet, en lui ajoutant toujours un même déplacement Δx.
  • Le mouvement vertical est uniformément accéléré. Cela signifie simplement qu'à chaque itération, vous devez ajouter à la coordonnée y un déplacement Δy qui augmente lui-même graduellement, toujours de la même quantité.

Voyons cela dans le script :

Pour commencer, il faut ajouter les lignes suivantes à la fin de la méthode constructeur. Elles vont servir à créer l'objet « obus », et à préparer une variable d'instance qui servira d'interrupteur de l'animation. L'obus est créé au départ avec des dimensions minimales (un cercle d'un seul pixel) afin de rester presqu'invisible :

        # dessiner un obus (réduit à un simple point, avant animation) :
        self.obus =boss.create_oval(x, y, x, y, fill='red')
        self.anim =False                   # interrupteur d'animation
        # retrouver la largeur et la hauteur du canevas :
        self.xMax =int(boss.cget('width'))
        self.yMax =int(boss.cget('height'))

Les deux dernières lignes utilisent la méthode cget() du widget « maître » (le canevas, ici), afin de retrouver certaines de ses caractéristiques. Nous voulons en effet que notre classe Canon soit généraliste, c'est-à-dire réutilisable dans n'importe quel contexte, et nous ne pouvons donc pas tabler à l'avance sur des dimensions particulières pour le canevas dans lequel ce canon sera utilisé.

 Tkinter renvoie ces valeurs sous la forme de chaînes de caractères. Il faut donc les convertir dans un type numérique si nous voulons pouvoir les utiliser dans un calcul.

Ensuite, nous devons ajouter deux nouvelles méthodes : l'une pour déclencher le tir, et l'autre pour gérer l'animation du boulet une fois que celui-ci aura été lancé :

    def feu(self):
        "déclencher le tir d'un obus"
        if not self.anim:
            self.anim =True
            # position de départ de l'obus (c'est la bouche du canon) :
            self.boss.coords(self.obus, self.x2 -3, self.y2 -3,
                                        self.x2 +3, self.y2 +3)
            v =15              # vitesse initiale
            # composantes verticale et horizontale de cette vitesse :
            self.vy = -v *sin(self.angle)
            self.vx = v *cos(self.angle)
            self.animer_obus()

    def animer_obus(self):
        "animation de l'obus (trajectoire balistique)"
        if self.anim:
            self.boss.move(self.obus, int(self.vx), int(self.vy))
            c = self.boss.coords(self.obus)     # coord. résultantes
            xo, yo = c[0] +3, c[1] +3   # coord. du centre de l'obus
            if yo > self.yMax or xo > self.xMax:
                self.anim =False        # arrêter l'animation
            self.vy += .5
            self.boss.after(30, self.animer_obus)
Commentaires
  • Lignes 1 à 4 : Cette méthode sera invoquée par appui sur un bouton. Elle déclenche le mouvement de l'obus, et attribue une valeur « vraie » à notre « interrupteur d'animation » (la variable self.anim : voir ci-après). Il faut cependant nous assurer que pendant toute la durée de cette animation, un nouvel appui sur le bouton ne puisse pas activer d'autres boucles d'animation parasites. C'est le rôle du test effectué à la ligne 3 : le bloc d'instruction qui suit ne peut s'exécuter que si la variable self.anim possède la valeur « faux », ce qui signifie que l'animation n'a pas encore commencé.
  • Lignes 5 à 7 : Le canevas Tkinter dispose de deux méthodes pour déplacer les objets graphiques. La méthode coords() effectue un positionnement absolu ; il faut cependant lui fournir toutes les coordonnées de l'objet (comme si on le redessinait). La méthode move(), utilisée à la ligne 17, provoque quant à elle un déplacement relatif ; elle s'utilise avec deux arguments seulement : les composantes horizontale et verticale du déplacement souhaité.
  • Lignes 8 à 12 : La vitesse initiale de l'obus est choisie à la ligne 8. Comme nous l'avons expliqué à la page précédente, le mouvement du boulet est la résultante d'un mouvement horizontal et d'un mouvement vertical. Nous connaissons la valeur de la vitesse initiale ainsi que son inclinaison (c'est-à-dire l'angle de tir). Pour déterminer les composantes horizontale et verticale de cette vitesse, il nous suffit d'utiliser des relations trigonométriques tout à fait similaires à que celles que nous avons déjà exploitées pour dessiner la buse du canon. Le signe - utilisé à la ligne 9 provient du fait que les coordonnées verticales se comptent de haut en bas. La ligne 12 active l'animation proprement dite.
  • Lignes 14 à 23 : Cette procédure se ré-appelle elle-même toutes les 30 millisecondes par l'intermédiaire de la méthode after() invoquée à la ligne 23. Cela continue aussi longtemps que la variable self.anim (notre « interrupteur d'animation ») reste « vraie », condition qui changera lorsque les coordonnées de l'obus sortiront des limites imposées (test de la ligne 20).
  • Lignes 18, 19 : Pour retrouver ces coordonnées après chaque déplacement, on fait appel encore une fois à la méthode coords() du canevas : utilisée avec la référence d'un objet graphique comme unique argument, elle renvoie ses quatre coordonnées dans un tuple.
  • Lignes 17 et 22 : La coordonnée horizontale de l'obus augmente toujours de la même quantité (mouvement uniforme), tandis que la coordonnée verticale augmente d'une quantité qui est elle-même augmentée à chaque fois à la ligne 24 (mouvement uniformément accéléré). Le résultat est une trajectoire parabolique.
 L'opérateur += permet d'incrémenter une variable : « a += 3 » équivaut à « a = a + 3 ».

Il reste enfin à ajouter un bouton déclencheur dans la fenêtre principale. Une ligne telle que la suivante (à insérer dans le code de test) fera parfaitement l'affaire :

    Button(f, text='Feu !', command =c1.feu).pack(side=LEFT)

Développement de l'application

modifier

Disposant désormais d'une classe d'objets « canon » assez bien dégrossie, nous pouvons à présent envisager l'élaboration de l'application proprement dite. Et puisque nous sommes décidés à exploiter la méthodologie de la programmation orientée objet, nous devons concevoir cette application comme un ensemble d'objets qui interagissent par l'intermédiaire de leurs méthodes.

 

Plusieurs de ces objets proviendront de classes préexistantes, bien entendu : ainsi le canevas, les boutons, etc. Mais nous avons vu dans les pages précédentes que nous avons intérêt à regrouper des ensembles bien délimités de ces objets basiques dans de nouvelles classes, chaque fois que nous pouvons identifier pour ces ensembles une fonctionnalité particulière. C'était le cas par exemple pour cet ensemble de cercles et de lignes mobiles que nous avons décidé d'appeler « canon ».

Pouvons-nous encore distinguer dans notre projet initial d'autres composants qui mériteraient d'être encapsulés dans des nouvelles classes ? Certainement. Il y a par exemple le pupitre de contrôle que nous voulons associer à chaque canon : nous pouvons y rassembler le dispositif de réglage de la hausse (l'angle de tir), le bouton de mise à feu, le score réalisé, et peut-être d'autres indications encore, comme le nom du joueur, par exemple. Il est d'autant plus intéressant de lui consacrer une classe particulière, que nous savons d'emblée qu'il nous en faudra deux instances.

Il y a aussi l'application elle-même, bien sûr. En l'encapsulant dans une classe, nous en ferons notre objet principal, celui qui dirigera tous les autres.

Veuillez à présent analyser le script ci-dessous. Vous y retrouverez la classe Canon() encore davantage développée : nous y avons ajouté quelques attributs et trois méthodes supplémentaires, afin de pouvoir gérer les déplacements du canon lui-même, ainsi que les coups au but.

La classe Application() remplace désormais le code de test des prototypes précédents. Nous y instancions deux objets Canon(), et deux objets de la nouvelle classe Pupitre(), que nous plaçons dans des dictionnaires en prévision de développements ultérieurs (nous pouvons en effet imaginer d'augmenter le nombre de canons et donc de pupitres). Le jeu est à présent fonctionnel : les canons se déplacent après chaque tir, et les coups au but sont comptabilisés.


from Tkinter import *
from math import sin, cos, pi
from random import randrange

class Canon:
    """Petit canon graphique"""
    def __init__(self, boss, id, x, y, sens, coul):
        self.boss = boss            # réf. du canevas
        self.appli = boss.master    # réf. de la fenêtre d'application 
        self.id = id                # identifiant du canon (chaîne)
        self.coul = coul            # couleur associée au canon
        self.x1, self.y1 = x, y     # axe de rotation du canon
        self.sens = sens            # sens de tir (-1:gauche, +1:droite)
        self.lbu = 30               # longueur de la buse
        self.angle = 0              # hausse par défaut (angle de tir)
        # retrouver la largeur et la hauteur du canevas :
        self.xMax = int(boss.cget('width'))
        self.yMax = int(boss.cget('height'))
        # dessiner la buse du canon (horizontale) :
        self.x2, self.y2 = x + self.lbu * sens, y
        self.buse = boss.create_line(self.x1, self.y1,
                                     self.x2, self.y2, width =10)
        # dessiner le corps du canon (cercle de couleur) :
        self.rc = 15                # rayon du cercle 
        self.corps = boss.create_oval(x -self.rc, y -self.rc, x +self.rc,
                                      y +self.rc, fill =coul)
        # pré-dessiner un obus caché (point en dehors du canevas) :
        self.obus = boss.create_oval(-10, -10, -10, -10, fill='red')
        self.anim = False           # indicateurs d'animation 
        self.explo = False          #    et d'explosion

    def orienter(self, angle):
        "régler la hausse du canon"
        # rem: le paramètre <angle> est reçu en tant que chaîne.
        # Il faut donc le traduire en réel, puis le convertir en radians :
        self.angle = float(angle)*pi/180
        # rem: utiliser la méthode coords de préférence avec des entiers :       
        self.x2 = int(self.x1 + self.lbu * cos(self.angle) * self.sens)
        self.y2 = int(self.y1 - self.lbu * sin(self.angle))
        self.boss.coords(self.buse, self.x1, self.y1, self.x2, self.y2)
 
    def deplacer(self, x, y):
        "amener le canon dans une nouvelle position x, y"
        dx, dy = x -self.x1, y -self.y1     # valeur du déplacement
        self.boss.move(self.buse, dx, dy) 
        self.boss.move(self.corps, dx, dy)
        self.x1 += dx
        self.y1 += dy
        self.x2 += dx
        self.y2 += dy

    def feu(self):
        "tir d'un obus - seulement si le précédent a fini son vol"
        if not (self.anim or self.explo):
            self.anim =True
            # récupérer la description de tous les canons présents :
            self.guns = self.appli.dictionnaireCanons()
            # position de départ de l'obus (c'est la bouche du canon) :
            self.boss.coords(self.obus, self.x2 -3, self.y2 -3,
                                        self.x2 +3, self.y2 +3)
            v = 17              # vitesse initiale
            # composantes verticale et horizontale de cette vitesse :
            self.vy = -v *sin(self.angle)
            self.vx = v *cos(self.angle) *self.sens
            self.animer_obus()
            return True     # => signaler que le coup est parti
        else:
            return False    # => le coup n'a pas pu être tiré

    def animer_obus(self):
        "animer l'obus (trajectoire balistique)"
        if self.anim:
            self.boss.move(self.obus, int(self.vx), int(self.vy))
            c = self.boss.coords(self.obus)     # coord. résultantes
            xo, yo = c[0] +3, c[1] +3      # coord. du centre de l'obus
            self.test_obstacle(xo, yo)     # a-t-on atteint un obstacle ?
            self.vy += .4                  # accélération verticale
            self.boss.after(20, self.animer_obus)
        else:
            # animation terminée - cacher l'obus et déplacer les canons :
            self.fin_animation()
   
    def test_obstacle(self, xo, yo):
        "évaluer si l'obus a atteint une cible ou les limites du jeu"
        if yo >self.yMax or xo <0 or xo >self.xMax:
            self.anim =False
            return
        # analyser le dictionnaire des canons pour voir si les coord.
        # de l'un d'entre eux sont proches de celles de l'obus :
        for id in self.guns:              # id = clef dans dictionn.
            gun = self.guns[id]           # valeur correspondante
            if xo < gun.x1 +self.rc and xo > gun.x1 -self.rc \
            and yo < gun.y1 +self.rc and yo > gun.y1 -self.rc :
                self.anim =False
                # dessiner l'explosion de l'obus (cercle jaune) :
                self.explo = self.boss.create_oval(xo -12, yo -12,
                             xo +12, yo +12, fill ='yellow', width =0)
                self.hit =id       # référence de la cible touchée
                self.boss.after(150, self.fin_explosion)
                break         
   
    def fin_explosion(self):
        "effacer l'explosion ; ré-initaliser l'obus ; gérer le score"
        self.boss.delete(self.explo)    # effacer l'explosion
        self.explo =False               # autoriser un nouveau tir
        # signaler le succès à la fenêtre maîtresse :
        self.appli.goal(self.id, self.hit)
        
    def fin_animation(self):
        "actions à accomplir lorsque l'obus a terminé sa trajectoire"
        self.appli.disperser()          # déplacer les canons
        # cacher l'obus (en l'expédiant hors du canevas) :
        self.boss.coords(self.obus, -10, -10, -10, -10)


class Pupitre(Frame):
    """Pupitre de pointage associé à un canon""" 
    def __init__(self, boss, canon):
        Frame.__init__(self, bd =3, relief =GROOVE)
        self.score =0
        self.appli =boss                # réf. de l'application
        self.canon =canon               # réf. du canon associé
        # Système de réglage de l'angle de tir :
        self.regl =Scale(self, from_ =75, to =-15, troughcolor=canon.coul,
                         command =self.orienter)
        self.regl.set(45)               # angle initial de tir
        self.regl.pack(side =LEFT)
        # Étiquette d'identification du canon :
        Label(self, text =canon.id).pack(side =TOP, anchor =W, pady =5)
        # Bouton de tir :
        self.bTir =Button(self, text ='Feu !', command =self.tirer)
        self.bTir.pack(side =BOTTOM, padx =5, pady =5)
        Label(self, text ="points").pack()
        self.points =Label(self, text=' 0 ', bg ='white')
        self.points.pack()
        # positionner à gauche ou à droite suivant le sens du canon :
        if canon.sens == -1:
            self.pack(padx =5, pady =5, side =RIGHT)
        else:
            self.pack(padx =5, pady =5, side =LEFT)

    def tirer(self):
        "déclencher le tir du canon associé"
        self.canon.feu()
        
    def orienter(self, angle):
        "ajuster la hausse du canon associé"
        self.canon.orienter(angle)

    def attribuerPoint(self, p):
        "incrémenter ou décrémenter le score, de <p> points"
        self.score += p
        self.points.config(text = ' %s ' % self.score)

class Application(Frame):
    '''Fenêtre principale de l'application'''
    def __init__(self):
        Frame.__init__(self)
        self.master.title('>>>>> Boum ! Boum ! <<<<<')
        self.pack()
        self.jeu = Canvas(self, width =400, height =250, bg ='ivory',
                          bd =3, relief =SUNKEN)
        self.jeu.pack(padx =8, pady =8, side =TOP)

        self.guns ={}           # dictionnaire des canons présents
        self.pupi ={}           # dictionnaire des pupitres présents
        # Instanciation de 2 'objets canons (+1, -1 = sens opposés) :
        self.guns["Billy"] = Canon(self.jeu, "Billy", 30, 200, 1, "red")
        self.guns["Linus"] = Canon(self.jeu, "Linus", 370,200,-1, "blue")
        # Instanciation de 2 pupitres de pointage associés à ces canons :
        self.pupi["Billy"] = Pupitre(self, self.guns["Billy"])
        self.pupi["Linus"] = Pupitre(self, self.guns["Linus"])

    def disperser(self):
        "déplacer aléatoirement les canons"
        for id in self.guns:
            gun =self.guns[id]
            # positionner à gauche ou à droite, suivant sens du canon :
            if gun.sens == -1 :
                x = randrange(320,380)
            else:
                x = randrange(20,80)
            # déplacement proprement dit :
            gun.deplacer(x, randrange(150,240))
  
    def goal(self, i, j):
        "le canon <i> signale qu'il a atteint l'adversaire <j>"
        if i != j:
            self.pupi[i].attribuerPoint(1)    
        else:
            self.pupi[i].attribuerPoint(-1)
            
    def dictionnaireCanons(self):
        "renvoyer le dictionnaire décrivant les canons présents" 
        return self.guns

if __name__ =='__main__':
    Application().mainloop()


Commentaires
  • Ligne 7 : Par rapport au prototype, trois paramètres ont été ajoutés à la méthode constructeur. Le paramètre id nous permet d'identifier chaque instance de la classe Canon() à l'aide d'un nom quelconque. Le paramètre sens indique s'il s'agit d'un canon qui tire vers la droite (sens = 1) ou vers la gauche (sens = -1). Le paramètre coul spécifie la couleur associée au canon.
  • Ligne 9 : Il faut savoir que tous les widgets Tkinter possèdent un attribut master qui contient la référence leur widget maître éventuel (leur « contenant »). Cette référence est donc pour nous celle de l'application principale. (Nous avons implémenté nous-mêmes une technique similaire pour référencer le canevas, à l'aide de l'attribut boss).
  • Lignes 42 à 50 : Cette méthode permet d'amener le canon dans un nouvel emplacement. Elle servira à repositionner les canons au hasard après chaque tir, ce qui augmente l'intérêt du jeu.
  • Lignes 56, 57 : Nous essayons de construire notre classe canon de telle manière qu'elle puisse être réutilisée dans des projets plus vastes, impliquant un nombre quelconque d'objets canons qui pourront apparaître et disparaître au fil des combats. Dans cette perspective, il faut que nous puissions disposer d'une description de tous les canons présents, avant chaque tir, de manière à pouvoir déterminer si une cible a été touchée ou non. Cette description est gérée par l'application principale, dans un dictionnaire, dont on peut lui demander une copie par l'intermédiaire de sa méthode dictionnaireCanons().
  • Lignes 66 à 68 : Dans cette même perspective généraliste, il peut être utile d'informer éventuellement le programme appelant que le coup a effectivement été tiré ou non.
  • Ligne 76 : L'animation de l'obus est désormais traitée par deux méthodes complémentaires. Afin de clarifier le code, nous avons placé dans une méthode distincte les instructions servant à déterminer si une cible a été atteinte (méthode test_obstacle()).
  • Lignes 79 à 81 : Nous avons vu précédemment que l'on interrompt l'animation de l'obus en attribuant une valeur « fausse » à la variable self.anim. La méthode animer_obus() cesse alors de boucler et exécute le code de la ligne 81.
  • Lignes 83 à 100 : Cette méthode évalue si les coordonnées actuelles de l'obus sortent des limites de la fenêtre, ou encore si elles s'approchent de celles d'un autre canon. Dans les deux cas, l'interrupteur d'animation est actionné, mais dans le second, on dessine une « explosion » jaune, et la référence du canon touché est mémorisée. La méthode annexe fin_explosion() est invoquée après un court laps de temps pour terminer le travail, c'est-à-dire effacer le cercle d'explosion et envoyer un message signalant le coup au but à la fenêtre maîtresse.
  • Lignes 115 à 153 : La classe Pupitre() définit un nouveau widget par dérivation de la classe Frame(), selon une technique qui doit désormais vous être devenue familière. Ce nouveau widget regroupe les commandes de hausse et de tir, ainsi que l'afficheur de points associés à un canon bien déterminé. La correspondance visuelle entre les deux est assurée par l'adoption d'une couleur commune. Les méthodes tirer() et orienter() communiquent avec l'objet Canon() associé, par l'intermédiaire des méthodes de celui-ci.
  • Lignes 155 à 172 : La fenêtre d'application est elle aussi un widget dérivé de Frame(). Son constructeur instancie les deux canons et leurs pupitres de pointage, en plaçant ces objets dans les deux dictionnaires self.guns et self.pupi. Cela permet d'effectuer ensuite divers traitements systématiques sur chacun d'eux (comme à la méthode suivante). En procédant ainsi, on se réserve en outre la possibilité d'augmenter sans effort le nombre de ces canons si nécessaire, dans les développements ultérieurs du programme.
  • Lignes 174 à 184 : Cette méthode est invoquée après chaque tir pour déplacer aléatoirement les deux canons, ce qui augmente la difficulté du jeu.

Développements complémentaires

modifier

Tel qu'il vient d'être décrit, notre programme correspond déjà plus ou moins au cahier des charges initial, mais il est évident que nous pouvons continuer à le perfectionner.

Nous devrions par exemple mieux le paramétrer. Qu'est-ce à dire ? Dans sa forme actuelle, notre jeu comporte un canevas de taille prédéterminée (400 x 250 pixels, voir ligne 161). Si nous voulons modifier ces valeurs, nous devons veiller à modifier aussi les autres lignes du script où ces dimensions interviennent (comme aux lignes 168-169, ou 179-184). De telles lignes interdépendantes risquent de devenir nombreuses si nous ajoutons encore d'autres fonctionnalités. Il serait donc plus judicieux de dimensionner le canevas à l'aide de variables, dont la valeur serait définie en un seul endroit. Ces variables seraient ensuite exploitées dans toutes les lignes d'instructions où les dimensions du canevas interviennent.

Nous avons déjà effectué une partie de ce travail : dans la classe Canon(), en effet, les dimensions du canevas sont récupérées à l'aide d'une méthode prédéfinie (voir lignes 17-18), et placées dans des attributs d'instance qui peuvent être utilisés partout dans la classe.

Après chaque tir, nous provoquons un déplacement aléatoire des canons, en redéfinissant leurs coordonnées au hasard. Il serait probablement plus réaliste de provoquer de véritables déplacements relatifs, plutôt que de redéfinir au hasard des positions absolues. Pour ce faire, il suffit de retravailler la méthode deplacer() de la classe Canon(). En fait, il serait encore plus intéressant de faire en sorte que cette méthode puisse produire à volonté, aussi bien un déplacement relatif qu'un positionnement absolu, en fonction d'une valeur transmise en argument.

Le système de commande des tirs devrait être amélioré : puisque nous ne disposons que d'une seule souris, il faut demander aux joueurs de tirer à tour de rôle, et nous n'avons mis en place aucun mécanisme pour les forcer à le faire. Une meilleure approche consisterait à prévoir des commandes de hausse et de tir utilisant certaines touches du clavier, qui soient distinctes pour les deux joueurs.

 

Mais le développement le plus intéressant pour notre programme serait certainement d'en faire une application réseau. Le jeu serait alors installé sur plusieurs machines communicantes, chaque joueur ayant le contrôle d'un seul canon. Il serait d'ailleurs encore plus attrayant de permettre la mise en œuvre de plus de deux canons, de manière à autoriser des combats impliquant davantage de joueurs.

Ce type de développement suppose cependant que nous ayons appris à maîtriser au préalable deux domaines de programmation qui débordent un peu le cadre de ce cours :

  • la technique des sockets, qui permet d'établir une communication entre deux ordinateurs ;
  • la technique des threads, qui permet à un même programme d'effectuer plusieurs tâches simultanément (cela nous sera nécessaire, si nous voulons construire une application capable de communiquer en même temps avec plusieurs partenaires).

Ces matières ne font pas strictement partie des objectifs que nous nous sommes fixés pour ce cours, et leur traitement nécessite à lui seul un chapitre entier. Nous n'aborderons donc pas cette question ici. Que ceux que le sujet intéresse se rassurent cependant : ce chapitre existe, mais sous la forme d'un complément à la fin du livre (chapitre 18) : vous y trouverez la version réseau de notre jeu de bombardes.

En attendant, voyons tout de même comment nous pouvons encore progresser, en apportant à notre projet quelques améliorations qui en feront un jeu pour 4 joueurs. Nous nous efforcerons aussi de mettre en place une programmation bien compartimentée, de manière à ce que les méthodes de nos classes soient réutilisables dans une large mesure. Nous allons voir au passage comment cette évolution peut se faire sans modifier le code existant, en utilisant l'héritage pour produire de nouvelles classes à partir de celles qui sont déjà écrites.

Commençons par sauvegarder notre ouvrage précédent dans un fichier, dont nous admettrons pour la suite de ce texte que le nom est : canon03.py.

Nous disposons ainsi d'un module Python, que nous pouvons importer dans un nouveau script à l'aide d'une seule ligne d'instruction. En exploitant cette technique, nous continuons à perfectionner notre application, en ne conservant sous les yeux que les nouveautés :

from Tkinter import *
from math import sin, cos, pi
from random import randrange
import canon03

class Canon(canon03.Canon):
    """Canon amélioré"""
    def __init__(self, boss, id, x, y, sens, coul):
        canon03.Canon.__init__(self, boss, id, x, y, sens, coul)
  
    def deplacer(self, x, y, rel =False):
        "déplacement, relatif si <rel> est vrai, absolu si <rel> est faux"
        if rel:
            dx, dy = x, y
        else:
            dx, dy = x -self.x1, y -self.y1
        # limites horizontales :
        if self.sens ==1:
            xa, xb = 20, int(self.xMax *.33)
        else:
            xa, xb = int(self.xMax *.66), self.xMax -20
        # ne déplacer que dans ces limites :
        if self.x1 +dx < xa:
            dx = xa -self.x1
        elif self.x1 +dx > xb:
            dx = xb -self.x1
        # limites verticales :
        ya, yb = int(self.yMax *.4), self.yMax -20
        # ne déplacer que dans ces limites :
        if self.y1 +dy < ya:
            dy = ya -self.y1
        elif self.y1 +dy > yb:
            dy = yb -self.y1
        # déplacement de la buse et du corps du canon :     
        self.boss.move(self.buse, dx, dy) 
        self.boss.move(self.corps, dx, dy) 
        # renvoyer les nouvelles coord. au programme appelant :
        self.x1 += dx
        self.y1 += dy
        self.x2 += dx
        self.y2 += dy
        return self.x1, self.y1  

    def fin_animation(self):
        "actions à accomplir lorsque l'obus a terminé sa trajectoire"
        # déplacer le canon qui vient de tirer :
        self.appli.depl_aleat_canon(self.id)
        # cacher l'obus (en l'expédiant hors du canevas) :
        self.boss.coords(self.obus, -10, -10, -10, -10)

    def effacer(self):
        "faire disparaître le canon du canevas"
        self.boss.delete(self.buse)
        self.boss.delete(self.corps)
        self.boss.delete(self.obus)        

class AppBombardes(Frame):
    '''Fenêtre principale de l'application'''
    def __init__(self, larg_c, haut_c):
        Frame.__init__(self)
        self.pack()
        self.xm, self.ym = larg_c, haut_c
        self.jeu = Canvas(self, width =self.xm, height =self.ym,
                          bg ='ivory', bd =3, relief =SUNKEN)
        self.jeu.pack(padx =4, pady =4, side =TOP)

        self.guns ={}           # dictionnaire des canons présents
        self.pupi ={}           # dictionnaire des pupitres présents
        self.specificites()     # objets différents dans classes dérivées

    def specificites(self):
        "instanciation des canons et des pupitres de pointage"
        self.master.title('<<< Jeu des bombardes >>>')
        id_list =[("Paul","red"),("Roméo","cyan"),
                  ("Virginie","orange"),("Juliette","blue")]
        s = False
        for id, coul in id_list:
            if s:
                sens =1
            else:
                sens =-1
            x, y = self.coord_aleat(sens)
            self.guns[id] = Canon(self.jeu, id, x, y, sens, coul)
            self.pupi[id] = canon03.Pupitre(self, self.guns[id])
            s = not s           # changer de côté à chaque itération

    def depl_aleat_canon(self, id):
        "déplacer aléatoirement le canon <id>"
        gun =self.guns[id]
        dx, dy = randrange(-60, 61), randrange(-60, 61)
        # déplacement (avec récupération des nouvelles coordonnées) :
        x, y = gun.deplacer(dx, dy, True)
        return x, y

    def coord_aleat(self, s):
        "coordonnées aléatoires, à gauche (s =1) ou à droite (s =-1)" 
        y =randrange(int(self.ym /2), self.ym -20)
        if s == -1:
            x =randrange(int(self.xm *.7), self.xm -20)
        else:
            x =randrange(20, int(self.xm *.3))
        return x, y
  
    def goal(self, i, j):
        "le canon n°i signale qu'il a atteint l'adversaire n°j"
        # de quel camp font-ils partie chacun ?
        ti, tj = self.guns[i].sens, self.guns[j].sens        
        if ti != tj :               # ils sont de sens opposés :
            p = 1                   # on gagne 1 point
        else:                       # ils sont dans le même sens :
            p = -2                  # on a touché un allié !!
        self.pupi[i].attribuerPoint(p)
        # celui qui est touché perd de toute façon un point :
        self.pupi[j].attribuerPoint(-1)

    def dictionnaireCanons(self):
        "renvoyer le dictionnaire décrivant les canons présents" 
        return self.guns

if __name__ =='__main__':
    AppBombardes(650,300).mainloop()
Commentaires
  • Ligne 6 : La forme d'importation utilisée à la ligne 4 nous permet de redéfinir une nouvelle classe Canon() dérivée de la précédente, tout en lui conservant le même nom. De cette manière, les portions de code qui utilisent cette classe ne devront pas être modifiées (Cela n'aurait pas été possible si nous avions utilisé par exemple : « from canon03 import * »).
  • Lignes 11 à 16 : La méthode définie ici porte le même nom qu'une méthode de la classe parente. Elle va donc remplacer celle-ci dans la nouvelle classe (On pourra dire également que la méthode deplacer() a été surchargée). Lorsque l'on réalise ce genre de modification, on s'efforce en général de faire en sorte que la nouvelle méthode effectue le même travail que l'ancienne quand elle est invoquée de la même façon que l'était cette dernière. On s'assure ainsi que les applications qui utilisaient la classe parente pourront aussi utiliser la classe fille, sans devoir être elles-mêmes modifiées. Nous obtenons ce résultat en ajoutant un ou plusieurs paramètres, dont les valeurs par défaut forceront l'ancien comportement. Ainsi, lorsque l'on ne fournit aucun argument pour le paramètre rel, les paramètres x et y sont utilisés comme des coordonnées absolues (ancien comportement de la méthode). Par contre, si l'on fournit pour rel un argument « vrai », alors les paramètres x et y sont traités comme des déplacements relatifs (nouveau comportement).
  • Lignes 17 à 33 : Les déplacements demandés seront produits aléatoirement. Il nous faut donc prévoir un système de barrières logicielles, afin d'éviter que l'objet ainsi déplacé ne sorte du canevas.
  • Ligne 42 : Nous renvoyons les coordonnées résultantes au programme appelant. Il se peut en effet que celui-ci commande un déplacement du canon sans connaître sa position initiale.
  • Lignes 44 à 49 : Il s'agit encore une fois de surcharger une méthode qui existait dans la classe parente, de manière à obtenir un comportement différent : après chaque tir, désormais on ne disperse plus tous les canons présents, mais seulement celui qui vient de tirer.
  • Lignes 51 à 55 : Méthode ajoutée en prévision d'applications qui souhaiteraient installer ou retirer des canons au fil du déroulement du jeu.
  • Lignes 57 et suivantes : Cette nouvelle classe est conçue dès le départ de manière telle qu'elle puisse aisément être dérivée. C'est la raison pour laquelle nous avons fragmenté son constructeur en deux parties : La méthode __init__() contient le code commun à tous les objets, aussi bien ceux qui seront instanciés à partir de cette classe que ceux qui seront instanciés à partir d'une classe dérivée éventuelle. La méthode specificites() contient des portions de code plus spécifiques : cette méthode est clairement destinée à être surchargée dans les classes dérivées éventuelles.

Jeu de Ping

modifier

Dans les pages qui suivent, vous trouverez le script correspondant à un petit programme complet. Ce programme vous est fourni à titre d'exemple de ce que vous pouvez envisager de développer vous-même comme projet personnel de synthèse. Il vous montre encore une fois comment vous pouvez utiliser plusieurs classes afin de construire un script bien structuré.

Principe

modifier

Le « jeu » mis en œuvre ici est plutôt une sorte d'exercice mathématique. Il se joue sur un panneau ou est représenté un quadrillage de dimensions variables, dont toutes les cases sont occupées par des pions. Ces pions possèdent chacun une face blanche et une face noire (comme les pions du jeu Othello/Reversi), et au début de l'exercice ils présentent tous leur face blanche par-dessus.

Lorsque l'on clique sur un pion à l'aide de la souris, les 8 pions adjacents se retournent.

Le jeu consiste alors à essayer de retourner tous les pions, en cliquant sur certains d'entre eux.

L'exercice est très facile avec une grille de 2 x 2 cases (il suffit de cliquer sur chacun des 4 pions). Il devient plus difficile avec des grilles plus grandes, et est même tout à fait impossible avec certaines d'entre elles. À vous de déterminer lesquelles ! (Ne négligez pas d'étudier le cas des grilles 1 x n)

 
 Vous trouverez la discussion complète du jeu de Ping, sa théorie et ses extensions, dans la revue « Pour la science » n° 298 - Août 2002, pages 98 à 102.

Programmation

modifier

Lorsque vous développez un projet logiciel, veillez toujours à faire l'effort de décrire votre démarche le plus clairement possible. Commencez par établir un cahier des charges détaillé, et ne négligez pas de commenter ensuite très soigneusement votre code, au fur et à mesure de son élaboration (et non après coup !).

En procédant ainsi, vous vous forcez vous-même à exprimer ce que vous souhaitez que la machine fasse, ce qui vous aide à analyser les problèmes et à structurer convenablement votre code.

Cahier des charges du logiciel à développer
  • L'application sera construite sur la base d'une fenêtre principale comportant le panneau de jeu et une barre de menus.
  • L'ensemble devra être extensible à volonté par l'utilisateur, les cases du panneau devant cependant rester carrées.
  • Les options du menu permettront de :
    • choisir les dimensions de la grille (en nombre de cases) ;
    • réinitialiser le jeu (c'est-à-dire disposer tous les pions avec leur face blanche au-dessus) ;
    • afficher le principe du jeu dans une fenêtre d'aide ;
    • terminer.(fermer l'application).
  • La programmation fera appel à trois classes :
    • une classe principale ;
    • une classe pour la barre de menus ;
    • une classe pour le panneau de jeu ;
  • Le panneau de jeu sera dessiné dans un canevas, lui-même installé dans un cadre (frame). En fonction des redimensionnements opérés par l'utilisateur, le cadre occupera à chaque fois toute la place disponible : il se présente donc au programmeur comme un rectangle quelconque, dont les dimensions doivent servir de base au calcul des dimensions de la grille à dessiner.
  • Puisque les cases de cette grille doivent rester carrées, il est facile de commencer par calculer leur taille maximale, puis d'établir les dimensions du canevas en fonction de celle-ci.
  • Gestion du clic de souris : on liera au canevas une méthode-gestionnaire pour l'événement <clic du bouton gauche>. Les coordonnées de l'événement serviront à déterminer dans quelle case de la grille (n° de ligne et n° de colonne) le clic a été effectué, quelles que soient les dimensions de cette grille. Dans les 8 cases adjacentes, les pions présents seront alors « retournés » (échange des couleurs noire et blanche).


###########################################
#  Jeu de ping                            #
#  Références : Voir article de la revue  #
#  <Pour la science>, Aout 2002           #
#                                         #
# (C) Gérard Swinnen (Verviers, Belgique) #
# http://www.ulg.ac.be/cifen/inforef/swi  #
#                                         #
#  Version du 29/09/2002 - Licence : GPL  #
###########################################

from Tkinter import *

class MenuBar(Frame):
    """Barre de menus déroulants"""
    def __init__(self, boss =None):
        Frame.__init__(self, borderwidth =2, relief =GROOVE)
        ##### Menu <Fichier> #####
        fileMenu = Menubutton(self, text ='Fichier')
        fileMenu.pack(side =LEFT, padx =5)
        me1 = Menu(fileMenu)
        me1.add_command(label ='Options', underline =0,
                        command = boss.options)
        me1.add_command(label ='Restart', underline =0,
                        command = boss.reset)
        me1.add_command(label ='Terminer', underline =0,
                        command = boss.quit)
        fileMenu.configure(menu = me1)    

        ##### Menu <Aide> #####
        helpMenu = Menubutton(self, text ='Aide')
        helpMenu.pack(side =LEFT, padx =5)
        me1 = Menu(helpMenu)
        me1.add_command(label ='Principe du jeu', underline =0,
                        command = boss.principe)
        me1.add_command(label ='À propos ...', underline =0,
                        command = boss.aPropos)
        helpMenu.configure(menu = me1)        
               
class Panneau(Frame):
    """Panneau de jeu (grille de n x m cases)"""
    def __init__(self, boss =None):
        # Ce panneau de jeu est constitué d'un cadre redimensionnable
        # contenant lui-même un canevas. À chaque redimensionnement du
        # cadre, on calcule la plus grande taille possible pour les
        # cases (carrées) de la grille, et on adapte les dimensions du
        # canevas en conséquence.
        Frame.__init__(self)
        self.nlig, self.ncol = 4, 4         # Grille initiale = 4 x 4
        # Liaison de l'événement <resize> à un gestionnaire approprié :
        self.bind("<Configure>", self.redim)
        # Canevas : 
        self.can =Canvas(self, bg ="dark olive green", borderwidth =0,
                         highlightthickness =1, highlightbackground ="white")
        # Liaison de l'événement <clic de souris> à son gestionnaire :
        self.can.bind("<Button-1>", self.clic)
        self.can.pack()
        self.initJeu()
    def initJeu(self):
        "Initialisation de la liste mémorisant l'état du jeu"
        self.etat =[]               	# construction d'une liste de listes
        for i in range(12):           # (équivalente à un tableau 
            self.etat.append([0]*12)	#  de 12 lignes x 12 colonnes) 

    def redim(self, event):
        "Opérations effectuées à chaque redimensionnement"
        # Les propriétés associées à l'événement de reconfiguration
        # contiennent les nouvelles dimensions du cadre : 
        self.width, self.height = event.width -4, event.height -4
        # La différence de 4 pixels sert à compenser l'épaisseur
        # de la 'highlightbordure" entourant le canevas)
        self.traceGrille()
        
    def traceGrille(self):
        "Dessin de la grille, en fonction des options & dimensions"
        # largeur et hauteur maximales possibles pour les cases :
        lmax = self.width/self.ncol        
        hmax = self.height/self.nlig
        # Le côté d'une case sera égal à la plus petite de ces dimensions :
        self.cote = min(lmax, hmax)
        # -> établissement de nouvelles dimensions pour le canevas :
        larg, haut = self.cote*self.ncol, self.cote*self.nlig
        self.can.configure(width =larg, height =haut)
        # Tracé de la grille :
        self.can.delete(ALL)                # Effacement dessins antérieurs
        s =self.cote                       
        for l in range(self.nlig -1):       # lignes horizontales
            self.can.create_line(0, s, larg, s, fill="white")
            s +=self.cote
        s =self.cote
        for c in range(self.ncol -1):       # lignes verticales
            self.can.create_line(s, 0, s, haut, fill ="white")
            s +=self.cote
        # Tracé de tous les pions, blancs ou noirs suivant l'état du jeu :    
        for l in range(self.nlig):
            for c in range(self.ncol):
                x1 = c *self.cote +5            # taille des pions = 
                x2 = (c +1)*self.cote -5        # taille de la case -10
                y1 = l *self.cote +5            #
                y2 = (l +1)*self.cote -5
                coul =["white","black"][self.etat[l][c]]
                self.can.create_oval(x1, y1, x2, y2, outline ="grey",
                                     width =1, fill =coul)


    def clic(self, event):
        "Gestion du clic de souris : retournement des pions"
        # On commence par déterminer la ligne et la colonne :
        lig, col = event.y/self.cote, event.x/self.cote
        # On traite ensuite les 8 cases adjacentes :
        for l in range(lig -1, lig+2):
            if l <0 or l >= self.nlig:
                continue
            for c in range(col -1, col +2):
                if c <0 or c >= self.ncol:
                    continue
                if l ==lig and c ==col:
                    continue
                # Retournement du pion par inversion logique :
                self.etat[l][c] = not (self.etat[l][c])
        self.traceGrille() 
           

class Ping(Frame):
    """corps principal du programme"""    
    def __init__(self):
        Frame.__init__(self)
        self.master.geometry("400x300")
        self.master.title(" Jeu de Ping")
        
        self.mbar = MenuBar(self)
        self.mbar.pack(side =TOP, expand =NO, fill =X)
        
        self.jeu =Panneau(self)
        self.jeu.pack(expand =YES, fill=BOTH, padx =8, pady =8)
        
        self.pack()
        
    def options(self):
        "Choix du nombre de lignes et de colonnes pour la grille"
        opt =Toplevel(self)
        curL =Scale(opt, length =200, label ="Nombre de lignes :",
              orient =HORIZONTAL,
              from_ =1, to =12, command =self.majLignes)
        curL.set(self.jeu.nlig)     # position initiale du curseur 
        curL.pack()
        curH =Scale(opt, length =200, label ="Nombre de colonnes :",
              orient =HORIZONTAL,        
              from_ =1, to =12, command =self.majColonnes)
        curH.set(self.jeu.ncol)      
        curH.pack()
    
    def majColonnes(self, n):
        self.jeu.ncol = int(n)
        self.jeu.traceGrille()
    
    def majLignes(self, n):
        self.jeu.nlig = int(n)      
        self.jeu.traceGrille()


    def reset(self):
        self.jeu.initJeu()
        self.jeu.traceGrille()

    def principe(self):
        "Fenêtre-message contenant la description sommaire du principe du jeu" 
        msg =Toplevel(self)
        Message(msg, bg ="navy", fg ="ivory", width =400,
            font ="Helvetica 10 bold", 
            text ="Les pions de ce jeu possèdent chacun une face blanche et "\
            "une face noire. Lorsque l'on clique sur un pion, les 8 "\
            "pions adjacents se retournent.\nLe jeu consiste à essayer "\
            "de les retourner tous.\n\nSi l'exercice se révèle très facile "\
            "avec une grille de 2 x 2 cases. Il devient plus difficile avec "\
            "des grilles plus grandes. Il est même tout à fait impossible "\
            "avec certaines grilles.\nÀ vous de déterminer lesquelles !\n\n"\
            "Réf : revue 'Pour la Science' - Aout 2002")\
            .pack(padx =10, pady =10)        

    def aPropos(self):
        "Fenêtre-message indiquant l'auteur et le type de licence" 
        msg =Toplevel(self)
        Message(msg, width =200, aspect =100, justify =CENTER,
            text ="Jeu de Ping\n\n(C) Gérard Swinnen, Aout 2002.\n"\
            "Licence = GPL").pack(padx =10, pady =10)
        
if __name__ == '__main__':
    Ping().mainloop()
 Rappel : Si vous souhaitez expérimenter ces programmes sans avoir à les réécrire, vous pouvez trouver leur code source à l'adresse :
http://www.ulg.ac.be/cifen/inforef/swi/python.htm
  1. Nous n'hésitons pas à discuter ici le développement d'un logiciel de jeu, parce qu'il s'agit d'un domaine directement accessible à tous, et dans lequel les objectifs concrets sont aisément identifiables. Il va de soi que les mêmes techniques de développement peuvent s'appliquer à d'autres applications plus « sérieuses ».
  2. Il s'agit d'une suite bureautique complète, libre et gratuite, largement compatible avec MS-Office, disponible pour Linux, Windows, MacOS, Solaris ... Le présent manuel a été entièrement rédigé avec son traitement de textes. Vous pouvez vous la procurer par téléchargement depuis le site Web : http://www.openoffice.org