Licence CC BY-NC-SA

TP : Jeu de billard

Ce contenu est obsolète. Il peut contenir des informations intéressantes mais soyez prudent avec celles-ci.

La fin du cours approche à grand pas et, après tous ces chapitres théoriques, il est temps de passer de nouveau à la pratique ! Cette partie consiste en un gros TP : en effet, nous allons créer un jeu de billard américain ! Une grande partie des notions que nous avons vues jusqu'ici seront utiles pour écrire votre jeu de billard, alors n'hésitez pas à revenir sur les passages sur lesquels vous ne vous sentez pas à l'aide avant d'entamer le TP.

C'est parti pour le grand TP final ! :pirate:

Objectifs

Les règles de notre billard

L'objectif de ce TP est de créer un jeu vidéo reprenant le jeu du billard américain.

Le billard est un jeu en deux équipes se déroulant sur une table de billard, avec plusieurs billes numérotées et de différentes couleurs. Chaque joueur manipule une sorte de bâton appelé la "queue" et a le droit de taper seulement dans la bille blanche. La table de billard (avec ou sans bandes) dispose de six trous dans lesquels il faut envoyer les billes de notre équipe.

Nous n'allons pas implémenter toutes les autres règles du billard américain pour simplifier le TP, nous laisserons le soin au joueur de décider comment il veut jouer. Toutefois, rien ne vous empêche de les implémenter vous-même par la suite pour vous entraîner ! ;)

Voici ce à quoi devrait ressembler notre jeu à peu de choses près :

En pleine partie de billard !

Le déroulement d'une partie

Phase 1 : Préparation de la partie

Cette phase est lancée lorsqu'une nouvelle partie démarre. Dans un premier temps, il faut placer le triangle des billes à droite de la table comme ceci :

Disposition initiale des billes

La bille 8 est toujours au centre de la troisième rangée, tandis que les autres billes peuvent être placées dans l'ordre que vous voulez.

La bille blanche peut être placée n'importe où (en général, soit sur le premier point blanc de la table, soit sur le deuxième au début de la partie). Nous pouvons alors laisser le choix de l'emplacement de cette bille au joueur lors de la phase suivante.

Phase 2 : Placement de la bille blanche

La bille blanche suit le curseur de la souris tout en ne pouvant sortir de la zone de jeu (en vert clair sur la table). Lorsque le joueur clique sur le bouton gauche de la souris, il faut placer la bille et passer à la phase suivante (phase 3).

On placera la bille blanche à la souris

Phase 3 : Visée avec la queue

La queue est alignée sur la bille blanche et le joueur peut viser la bille blanche en faisant bouger la souris. La distance entre le bout de la queue et la bille blanche détermine la puissance du coup porté. Au clic de la souris, on tape dans la bille blanche et c'est au tour de la phase 4.

On visera avec la queue de billard

Il serait préférable de limiter la distance maximum de la queue avec la bille blanche afin d'éviter que la puissance du coup soit trop élevée. On peut également envisager de poser une limite de distance minimum pour que le joueur ne puisse pas faire de coup sans puissance du tout.

Phase 4 : Simulation des billes

Durant cette phase, il faut calculer le déplacement des billes à chaque avancée d'image. Si deux billes entrent en collision, un calcul de choc élastique permet de déterminer leur nouvelle direction et vitesse de déplacement.
Si une bille entre en collision de bordure avec un trou, la bille est retirée du jeu jusqu'à la prochaine partie. S'il s'agit de la bille blanche, il faut la replacer : nous retournons donc à la phase 2. Une fois les billes toutes rentrées (hors bille blanche), nous pouvons recommencer une partie et donc retourner à la phase 1.

Pendant qu'elle se déplace, chaque bille doit tourner sur elle-même en fonction de sa vitesse de déplacement, afin de donner un minimum d'impression de mouvement.

Comme le calcul des chocs élastiques est un peu mathématique et technique et que cela n'est pas l'objet du cours, le code correspondant vous sera fourni, je vous rassure. ;)

Consigne supplémentaire

Afin que notre jeu soit utilisable sur un petit écran, il serait préférable de pouvoir déplacer le billard à l'aide du bouton droit de la souris.

Préparation et conseils

Préparation du projet

Dossier et ressources

Avant de commencer le TP, il nous faut préparer un peu le terrain. Commencez par créer un nouveau dossier (ou projet Flashdevelop) appellé Billard. Copiez le dossier com de la librairie GSAP (que nous avons découverte dans le chapitre sur l'animation) dans le dossier src où se trouve votre classe principale Main.

Nous allons également utiliser une image et des polices de caractères à placer dans un dossier lib qui seront embarquées dans notre application. Pour cela, je vous ai préparé une archive à extraire directement dans le dossier de votre projet :

Télécharger les ressources

Ces ressources comprennent un dossier img contenant la texture de fond de notre application et un dossier fonts contenant les fichiers de police de caractères à inclure à l'aide d'une classe EmbedFonts à créer sur le modèle de celle que nous avons vue dans le chapitre sur le texte.

Premières classes utiles

Vous pouvez déjà créer ces deux classes contenant des valeurs utiles pour notre projet. Tout d'abord, les constantes des unités de mesure dans un fichier Unite.as :

1
2
3
4
5
6
7
8
package  
{
    public class Unite 
    {
        public static const METRE:Number = 400;
        public static const GRAMME:Number = 1;
    }
}

Puis, les constantes mathématiques dont nous aurons besoin pour certains calculs dans un fichier Constantes.as :

1
2
3
4
5
6
7
8
package  
{
    public class Constantes 
    {
        public static var restitution:Number = 0.85;
        public static var frottement:Number = 0.007;
    }
}

Il pourrait être utile de pouvoir modifier ces "constantes" mathématique pendant le jeu (par exemple, dans un écran d'options), afin de permettre au joueur d'affiner ses préférences au niveau du comportement des billes. Nous ne le ferons pas dans le cadre de ce TP, mais cela peut être une idée d'amélioration. ;)

Enfin, voici quelques fonctions mathématiques rassemblées au sien de cette classe qui pourront vous être utiles :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package  
{
    import flash.display.DisplayObject;
    /**
     * Quelques fonctions utiles.
     * @author Guillaume CHAU
     */
    public class Outils 
    {
        /**
         * Calcule et renvoie la distance (en pixels) entre deux objets.
         * @param    obj1
         * @param    obj2
         * @return    Distance en pixels
         */
        static public function distance(obj1:DisplayObject, obj2:DisplayObject):Number
        {
            return distanceNombres(obj1.x, obj1.y, obj2.x, obj2.y);
        }

        /**
         * Calcule et renvoie la distance (en pixels) entre les deux points formés par les coordonnées parssées en paramètre.
         * @param    x1
         * @param    y1
         * @param    x2
         * @param    y2
         * @return    Distance en pixels
         */
        static public function distanceNombres(x1:Number, y1:Number, x2:Number, y2:Number):Number
        {
            var X:Number = x2 - x1;
            var Y:Number = y2 - y1;
            return Math.sqrt(X * X + Y * Y);
        }

        /**
         * Calcule et renvoie l'angle (en radians) entre les deux objets.
         * @param    obj1
         * @param    obj2
         * @return    Angle en radians
         */
        static public function angle(obj1:DisplayObject, obj2:DisplayObject):Number
        {
            return angleNombres(obj1.x, obj1.y, obj2.x, obj2.y);
        }

        /**
         * Calcule et renvoie l'angle (en radians) entre les deux points formés par les coordonnées passées en paramètre.
         * @param    x1
         * @param    y1
         * @param    x2
         * @param    y2
         * @return    Angle en radians
         */
        static public function angleNombres(x1:Number, y1:Number, x2:Number, y2:Number):Number
        {
            return Math.atan2(y2 - y1, x2 - x1);
        }
    }
}

A quoi servent ces commentaires un peu étranges devant les propriétés publiques ?

1
2
3
4
5
6
/**
 * Calcule et renvoie la distance (en pixels) entre deux objets.
 * @param    obj1
 * @param    obj2
 * @return    Distance en pixels
 */

Ces commentaires sont des commentaires de documentation. Il permettent de décrire, dans un format standardisé, le fonctionnement de la propriété. Si vous voulez en savoir plus, je vous invite à consulter le chapitre correspondant dans les annexes.

Les différents éléments du jeu

Voici les différents éléments qui composerons notre jeu du billard. Il peut être judicieux de définir nos classes à partir de ces éléments. ;)

La table

La table peut être représentée par un rectangle arrondi marron contenant un autre rectangle arrondi vert avec quelques marques blanches transparentes pour aider à placer la bille blanche. La zone jouable en vert clair mesure 2,34 m de largeur et 1,17 m de hauteur ; la marge de la partie verte foncée fait 0,08 m (8 cm). Nous pouvons par exemple définir les différentes tailles ainsi en utilisant notre classe Unite :

1
2
3
var tableLargeur:Number = 2.34 * Unite.METRE;
var tableHauteur:Number = 1.17 * Unite.METRE;
var margeFond:Number = 0.08 * Unite.METRE;

Voici ce à quoi la table doit ressembler :

Le rendu final de la table

Pour obtenir ce résultat, il faut dessiner en plusieurs étapes.

Tout d'abord, dessinons un rectangle de couleur 0x0c351e (vert foncé) avec une bordure de taille 5, de couleur 0x432e0e (marron) et arrondie de 32 pixels :

Première étape : le fond

Ajoutons un dessin (par exemple avec la classe Shape) dans notre table qui représentera la zone de jeu, avec un rectangle de couleur 0x006029 (vert clair) avec bords arrondis de 10 pixels, sans bordure :

Etape 2 : zone de jeu

Nous pouvons ajouter un filtre d'ombre portée interne de couleur noire à la zone de jeu pour donner un peu de relief.

Ensuite, ajoutons trois lignes blanches transparentes (alpha à 15%) avec un petit disque au centre (alpha à 30%) :

Etape 3 : marquage blanc

Enfin, nous pouvons ajouter un filtre d'ombre portée pour simuler le volume de la table :

Le rendu final de la table

Les trous

Chaque trou fait 0.069 m (ou 6,9 cm) de diamètre. Il sera constitué d'un simple disque de couleur noire :

Un trou de billard

Il y en a six à disposer autour de la zone de jeu de cette manière :

La position de chaque trou

Les billes

Chaque bille sera représentée par un disque de couleur, avec un numéro au centre. Une bille sur deux aura des bandes blanches (sauf la bille blanche et la bille 8) pour différencier les deux équipes. Leur diamètre est de 0.07 m (ou 7 cm), et leur masse vaut 172 g :

1
2
var diametre:Number = 0.05 * Unite.METRE;
var masse:Number = 172 * Unite.GRAMME;

Voici les 16 couleurs réglementaires des billes dans l'ordre (la bille numéro zéro est la bille blanche) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
var couleurs:Array = [
    0xffffff, // Bille blanche
    0xfac92e, 
    0x1744c1, 
    0xee372e, 
    0x271d53, 
    0xff8d59, 
    0x189e66, 
    0x931422, 
    0x000000, // Bille 8
    0xf7af1d, 
    0x2059e8, 
    0xde291d, 
    0x2f2665, 
    0xfb8351, 
    0x0d955c, 
    0x92121d
];

Il est temps de mettre en forme ces billes ! Prenons le cas de la bille 13 et commençons par dessiner un disque (centré sur l'origine) de la couleur de la bille :

Etape 1 : disque de couleur

Si le numéro de la bille est supérieur ou égal à 9, il faut ajouter des bandes blanches comme ceci :

Etape 2 : éventuelles bandes blanches

Pensez à utiliser un masque circulaire pour faire prendre aux deux bandes la forme circulaire de la bille.

Ensuite, si le numéro est supérieur à zéro (donc si ce n'est pas la bille blanche), on ajoute un champ de texte noir contenant le numéro avec un disque blanc dessiné en dessous :

Etape 3 : étiquette du numéro

Souvenez-vous : il faut utiliser des polices de caractères embarquées dans notre application, sinon on ne pourra pas faire tourner les billes sans que le texte ne disparaisse !

Enfin, pour donner un peu de volume à nos billes, nous pouvons ajouter quelques filtres sur chacune :

1
2
3
4
5
6
filters = [
    // Ombre
    new DropShadowFilter(3, 45, 0, 0.3, 6, 6, 1, 2),
    // Volume (ombre intérieure)
    new DropShadowFilter( -10, 45, 0, 0.3, 6, 6, 1, 2, true)
];

Ce qui nous donne :

Etape 4 : filtres d'ombrage

La queue

La queue est un bâton de bois équipé d'un manche plus confortable pour bien la tenir. Elle mesure 1,5 m de long et 0,025 m (ou 2,5 cm) de large.

Encore une fois, on peut utiliser les dessins pour parvenir à nos fins : un rectangle de couleur noire pour le manche et un autre rectangle légèrement plus fin de couleur 0x3e2317 (marron) pour le bâton :

Queue de billard

Je vous recommande de dessiner la queue de telle sorte que son origine soit au bout, comme sur la figure ci-dessus.

Ce dessin doit être placé dans un objet de dessin (Shape) qui sera contenu dans notre classe Queue représentant la queue afin de pouvoir la centrer sur la bille blanche et bouger uniquement le dessin comme ceci :

On place la queue sur la bille blanche et on ne bouge que le dessin

Nous pouvons éventuellement ajouter un trait blanc semi-transparent comme viseur, un texte précisant la puissance du coup qui sera porté en fonction de la distance par rapport à la bille blanche et quelques effets d'ombre :

Quelques ajouts sur la queue de billard

Encore une fois, il faut utiliser des polices de caractères embarquées pour éviter que le texte ne disparaisse à la rotation de la queue de billard.

Le fond

Le fond de notre scène sera un dessin couvrant la taille de la fenêtre rempli avec cette texture :

Texture de fond

Il faut donc ajouter un écouteur sur l'événement Event.RESIZE sur la scène principale pour détecter le changement de taille. Pour remplir avec une image, nous utiliserons la méthode beginBitmapFill() de la classe Graphics.

Un peu de Maths

Voici quelques portions de code permettant d'effectuer les calculs sortants un peu du cadre du cours.

Vecteur géométrique

En premier lieu, nous aurons besoin de vecteurs géométriques à deux dimensions, dotés de quatre attributs pour nos calculs : x, y, module et angle :

Un vecteur géométrique à deux dimensions

Voici la classe Vecteur2D représentant un vecteur géométrique à deux dimensions :

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
package  
{
    /**
     * Un vecteur x;y, pour les calculs
     * @author Guillaume CHAU
     */
    public class Vecteur2D 
    {
        private var _x:Number;
        private var _y:Number;

        private var _module:Number = 0;
        private var _angle:Number = 0;

        public function Vecteur2D(x:Number = 0, y:Number = 0) 
        {
            _x = x;
            _y = y;

            _calculAngleModule();
        }

        public function toString():String 
        {
            return "[Vecteur2D x=" + x + " y=" + y + " module=" + module + " angle=" + angle + "]";
        }

        /**
         * Revoie une copie du vecteur.
         * @return
         */
        public function clone():Vecteur2D
        {
            var c:Vecteur2D = new Vecteur2D();
            c.x = _x;
            c.y = _y;
            return c;
        }

        /**
         * Ajoute le vecteur passé en paramètre au vecteur courant.
         * @param vecteur   Vecteur à ajouter.
         * @param apply     Si vrai, modifie directement le vecteur courant. Sinon, renvoie un nouveau vecteur.
         * @return          Le vecteur représentant la somme des deux premiers vecteurs.
         */
        public function ajouter(vecteur:Vecteur2D, apply:Boolean = false):Vecteur2D
        {
            var cible:Vecteur2D;
            if (apply)
            {
                cible = this;
            }
            else
            {
                cible = clone();
            }
            cible.x += vecteur.x;
            cible.y += vecteur.y;
            return cible;
        }

        /**
         * Soustrait le vecteur passé en paramètre au vecteur courant.
         * @param vecteur   Vecteur à soustraire.
         * @param apply     Si vrai, modifie directement le vecteur courant. Sinon, renvoie un nouveau vecteur.
         * @return          Le vecteur représentant la soustraction des deux premiers vecteurs.
         */
        public function soustraire(vecteur:Vecteur2D, apply:Boolean = false):Vecteur2D
        {
            var cible:Vecteur2D;
            if (apply)
            {
                cible = this;
            }
            else
            {
                cible = clone();
            }
            cible.x -= vecteur.x;
            cible.y -= vecteur.y;
            return cible;
        }

        /**
         * Multiplie le vecteur par un coefficient numérique.
         * @param coef      Coefficient numérique.
         * @param apply     Si vrai, modifie directement le vecteur courant. Sinon, renvoie un nouveau vecteur.
         * @return          Le vecteur ayant reçu la multiplication.
         */
        public function multiplier(coef:Number, apply:Boolean = false):Vecteur2D
        {
            var cible:Vecteur2D;
            if (apply)
            {
                cible = this;
            }
            else
            {
                cible = clone();
            }
            cible.x *= coef;
            cible.y *= coef;
            return cible;
        }

        /**
         * Normalise le vecteur (c'est-à-dire modifie ses coordonnées x et y telles que son module vaille 1).
         * @param apply     Si vrai, modifie directement le vecteur courant. Sinon, renvoie un nouveau vecteur.
         * @return          Le vecteur normalisé.
         */
        public function normaliser(apply:Boolean = false):Vecteur2D
        {
            var cible:Vecteur2D;
            if (apply)
            {
                cible = this;
            }
            else
            {
                cible = clone();
            }
            var m:Number = cible.module;
            if (m != 0)
            {
                cible.x /= m;
                cible.y /= m;
            }
            else
            {
                cible.x = 0;
                cible.y = 0;
            }
            return cible;
        }

        /**
         * Calcule le produit du vecteur courant avec le vecteur passé en paramètre.
         * @param vecteur       Le vecteur à multiplier avec le vecteur courant.
         * @return              Nombre résultant de la multiplication des deux vecteurs.
         */
        public function produit(vecteur:Vecteur2D):Number
        {
            return this.x * vecteur.x + this.y + vecteur.y;
        }

        /* PRIVE */

        private function _calculCoord():void
        {
            if (_module == 0)
            {
                _x = 0;
                _y = 0;
            }
            else
            {
                _x = Math.cos(angle) * _module;
                _y = Math.sin(angle) * _module;
            }
        }

        private function _calculAngleModule():void
        {
            if (_x == 0 && _y == 0)
            {
                _angle = 0;
                _module = 0;
            }
            else
            {
                _module = Outils.distanceNombres(0, 0, _x, _y);
                _angle = Outils.angleNombres(0, 0, _x, _y);
            }
        }

        /* GETTERS */

        /**
         * Coordonnée horizontale.
         */
        public function get x():Number 
        {
            return _x;
        }

        public function set x(value:Number):void 
        {
            _x = value;

            _calculAngleModule();
        }

        /**
         * Coordonnée verticale.
         */
        public function get y():Number 
        {
            return _y;
        }

        public function set y(value:Number):void 
        {
            _y = value;

            _calculAngleModule();
        }

        /**
         * Module (longeur du vecteur).
         */
        public function get module():Number 
        {
            return _module;
        }

        public function set module(value:Number):void 
        {
            _module = value;

            _calculCoord();
        }

        /**
         * Angle du vecteur par rapport à l'horizontale.
         */
        public function get angle():Number 
        {
            return _angle;
        }

        public function set angle(value:Number):void 
        {
            _angle = value;

            _calculCoord();
        }

    }
}

Cette classe sera notamment utile pour calculer les changements de trajectoires entre les billes ou, pourquoi pas, représenter le déplacement de chaque bille.

Il peut souvent être intéressant de représenter des concepts abstraits (comme les vecteurs géométriques) en classes à par entière afin de faciliter les calculs par la suite et de rendre le tout plus clair et lisible.

Choc élastique

Lorsque deux billes de billard se rencontrent, il se produit un choc élastique : la trajectoire et la vitesse des deux billes est alors modifiée comme on peut le remarquer dans la figure suivante :

Choc élastique entre deux billes

Pour le projet, partons du principe que chaque bille possède un attribut velocite de classe Vecteur2D qui représente son déplacement (horizontal et vertical) dans la zone de jeu. Pour la phase de simulation du jeu de billard, il faut alors déplacer chaque bille en fonction de la valeur des coordonnées du vecteur (velocite.x pour le déplacement horizontal et velocite.y pour le déplacement vertical), et ceci régulièrement pour donner l'illusion du déplacement (par exemple, à chaque changement de frame grâce à l'événement Event.ENTER_FRAME).

On peut calculer les nouvelles trajectoires de deux billes entrant en collision à l'aide de ce calcul un peu complexe que je vous ai préparé :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
var taille:Number = (this.taille + bille.taille) / 2;
// Repositionnement au point de contact
// Vecteur distance entre les boules
var delta:Vecteur2D = new Vecteur2D(this.x - bille.x, this.y - bille.y);
var d:Number = delta.module;
// Distance de translation minimum pour pousser les boules
if (d == 0)
{
    d = (this.taille + bille.taille) * 0.5 - 1;
    delta = new Vecteur2D((this.taille + bille.taille) * 0.5, 0);
}
var mtd:Vecteur2D = delta.multiplier(((this.taille + bille.taille) * 0.5 - d) / d);
// Masse inverse
var im1:Number = 1 / this.masse;
var im2:Number = 1 / bille.masse;
// Repousser les balles pour éviter qu'elles se chevauchent
var correction:Vecteur2D = mtd.multiplier(im1 / (im1 + im2));
this.x += correction.x;
this.y += correction.y;
bille.x -= correction.x;
bille.y -= correction.y;
// Calcul de la base orthonormée (n,g)
// n est perpendiculaire au plan de collision, g est tangent
var nx:Number = (bille.x - this.x) / taille;
var ny:Number = (bille.y - this.y) / taille;
var gx:Number = -ny;
var gy:Number = nx;
// Calcul des vitesses dans cette base
var v1n:Number = nx * this.velocite.x + ny * this.velocite.y;
var v1g:Number = gx * this.velocite.x + gy * this.velocite.y;
var v2n:Number = nx * bille.velocite.x + ny * bille.velocite.y;
var v2g:Number = gx * bille.velocite.x + gy * bille.velocite.y;
// Permute les coordonnées n et conserve la vitesse tangentielle
// Exécute la transformation inverse (base orthonormée => matrice transposée)
this.velocite.x = nx * v2n + gx * v1g;
this.velocite.y = ny * v2n + gy * v1g;
bille.velocite.x = nx * v1n + gx * v2g;
bille.velocite.y = ny * v1n + gy * v2g;

L'objet courant de ce calcul (c'est-à-dire l'objet pointé par le mot-clé this) est la bille qui est entrée en collision avec la deuxième bille, contenue dans la variable bille.

Après ce calcul, l'objet velocite de chaque bille contient le nouveau déplacement horizontal (velocite.x) et le nouveau déplacement vertical (velocite.y) de la bille.

Organiser son projet

Maintenant que vous avez toutes les clés en mains, il faut organiser votre projet. Posez-vous plusieurs questions avant de commencer à coder quoi que ce soit !
Commencez par dessiner sur une feuille les classes que vous allez créer, réfléchissez à la façon dont elles vont interagir entre elles, si il y aura des classes mères, etc. Pour vous donner un ordre d'idée, mon projet compte au total 12 classes (hors librairie GSAP), mais vous pouvez tout à fait en créer plus ou moins que moi. ;)
Ensuite, représentez le déroulement d'une partie de billard pour l'avoir bien en tête lorsque vous implémenterez la logique de ce jeu de billard.
Et surtout, n'hésitez par à parcourir à nouveau certaines sections du cours. Essayez de produire quelque chose, même si cela n'a pas autant de fonctionnalités que vous l'espériez, avant de regarder la correction : on apprend le mieux en pratiquant soi-même !

A vos stylos et claviers ! :pirate:

Correction : Organisation des classes

La structure de l'application

Avant de commencer à programmer tête baissée, il faut réfléchir à la structure du projet. Avec quelques feuilles et un crayon, vous pouvez commencer à organiser votre application, ce travail est très important pour que votre projet soit clair et lisible (y compris pour vous-même). Je vais vous présenter la structure que j'ai choisie, qui peut tout à fait différer de la vôtre, il n'y a pas une seule solution à ce problème, ne l'oubliez pas !

J'ai donc articulé mon application autour d'une classe nommée Billard qui représente le jeu de billard que nous concevons. Cette classe utilise d'autres classes pour composer les différents éléments du jeu : la table, les trous, les billes et la queue, qui sont les différents objets visuels. La logique du jeu de billard est également contenue dans la classe Billard. A côté de cette classe, nous retrouvons la classe Vecteur2D servant à faire plusieurs calculs (comme les déplacements des billes et les chocs élastiques). Enfin, la classe Main représente le programme principal qui va créer un jeu de billard à l'aide de la classe Billard.

Voici le diagramme des classes principales de l'application :

Diagramme des classes

Les autres classes annexes (comme les classes Unite et Constantes) ne sont pas représentées sur ce diagramme pour des raisons de lisibilité.

Description des différents classes

Nous avons donc huit classes principales :

  • Classe Main : programme principal, chargé de gérer la création du jeu du billard et la texture de fond de l'application.
  • Classe Billard : représente le jeu du billard, c'est-à-dire sa logique (avec les quatre phases de jeu) et ses différents éléments.
  • Classe Visuel : classe mère des éléments composants le jeu.
  • Classe Table : consiste principalement en un dessin de la table de billard et gestion des collisions entre les billes et les bords de la zone de jeu.
  • Classe Trou : dessin d'un trou de la table de billard avec la gestion des collisions des billes avec ce trou .
  • Classe Bille : dessin d'une bille de billard (avec un numéro), ainsi que la gestion du déplacement de la bille et des collisions entre deux billes.
  • Classe Queue : dessin de la queue de billard (avec les indicateurs visuels).
  • Classe Vecteur2D : représente un vecteur géométrique à deux dimensions pour les calculs.

Correction : Création des classes

Dans cette partie, nous allons détailler les différentes classes du projet, une à une. Toutefois, essayez quand même d'aboutir à un résultat par vos propres moyens avant de vous jeter sur cette correction, cela vous aidera à progresser ! ;)

Visuel

Commençons par créer la classe de base de nos objets graphiques, la classe Visuel. Cette classe va gérer la taille des objets visuels et va faire en sorte de les dessiner à chaque modification de taille en partant de la classe d'affichage Sprite.
Grâce au polymorphisme, nous pouvons redéfinir les accesseurs width et height de la classe Sprite de telle sorte que la méthode protégée _dessiner soit appelée automatiquement dès qu'un changement de taille intervient : ainsi, nos objets graphiques seront redessinés dès qu'il sera nécessaire !

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package  
{
    import flash.display.Sprite;

    /**
     * Classe de base des objets graphiques.
     * @author Guillaume CHAU
     */
    public class Visuel extends Sprite 
    {
        // Taille "virtuelle"
        private var _width:Number = 0;
        private var _height:Number = 0;

        private var _taille:Number = 0;

        public function Visuel() 
        {

        }

        /* PRIVE */

        protected function _dessiner():void
        {
            // A implémenter dans les sous-classes
        }

        /* GETTERS */

        override public function get width():Number 
        {
            return _width;
        }

        override public function set width(value:Number):void 
        {
            _width = value;

            _dessiner();
        }

        override public function get height():Number 
        {
            return _height;
        }

        override public function set height(value:Number):void 
        {
            _height = value;

            _dessiner();
        }

        /**
         * Taille de l'objet visuel au cas où il n'a besoin que d'un nombre (exemple : diamètre).
         */
        public function get taille():Number 
        {
            return _taille;
        }

        public function set taille(value:Number):void 
        {
            _taille = value;

            _dessiner();
        }
    }
}

J'ai ajouté un attribut taille pour les objets n'ayant besoin que d'un nombre pour leur taille (comme les billes et les trous de la table).

Au lieu de laisser la classe Sprite redimensionner (étirer) l'objet comme d'habitude, nous stockons les nouvelles informations de taille (que j'ai appelées les informations de taille virtuelle), puis nous utiliserons ses informations dans la méthode _dessiner (redéfinie dans les sous-classes) afin de redessiner les objets graphiques à la bonne taille.

Bille

La classe Bille (et les autres objets graphiques du jeu de billard) est une sous-classe de la classe Visuel. Commençons donc par lui faire hériter la classe Visuel :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package  
{
    /**
     * Bille de billard.
     * @author Guillaume CHAU
     */
    public class Bille extends Visuel 
    {

    }
}

Si vous utilisez Flashdevelop, il est possible de spécifier la classe mère lorsque vous créez une classe pour que l'IDE écrive le code correspondant automatiquement (avec les imports). Il suffit de la renseigner dans le champ Base Class (Superclasse) de la fenêtre de création de classe. Cliquez d'abord sur le bouton Browse (Parcourir) en face du champ Base Class :

Fenêtre de création de classe

Puis tapez le nom de la classe mère et sélectionnez-là :

Sélection de la classe mère

Une fois la classe créée, ajoutons-lui les attributs des billes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Infos de bases
private var _couleur:uint;
private var _numero:int;
private var _masse:Number;
// Vraie si la bille est tombée dans un trou
private var _horsJeu:Boolean = false;
// Eléments visuels
private var _masque:Shape;
private var _etiquette:TextField;
// Vecteur de déplacement
private var _velocite:Vecteur2D;

Sans oublier les accesseurs correspondants :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
 * Couleur de la bille (exemple: 0xFF0055).
 */
public function get couleur():uint
{
    return _couleur;
}
/**
 * Numéro de la bille. Si le numéro est 0, la bille n'a pas d'étiquette. Si le numéro est supérieur ou égal à 9, la bille a des bandes blanches.
 */
public function get numero():int
{
    return _numero;
}
/**
 * Masse de la bille. Utiliser Unite.GRAMME.
 */
public function get masse():Number
{
    return _masse;
}
public function set masse(value:Number):void
{
    _masse = value;
}
/**
 * Vecteur géométrique représentant le déplacement de la bille par frame.
 */
public function get velocite():Vecteur2D 
{
    return _velocite;
}
public function set velocite(value:Vecteur2D):void 
{
    _velocite = value;
}
/**
 * Indique si la bille est hors-jeu (ex: tombée dans un trou).
 */
public function get horsJeu():Boolean 
{
    return _horsJeu;
}
public function set horsJeu(value:Boolean):void 
{
    _horsJeu = value;
}

Constructeur

Le constructeur de la bille créé les différents objets contenus dans la bille : l'étiquette du numéro et le vecteur de déplacement. De plus, il reçoit en paramètre les informations de bases sur la bille (couleur, numéro, taille et masse).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public function Bille(couleur:uint, numero:int, taille:Number, masse:Number)
{
    // Etiquette affichant le numéro de la bille
    _etiquette = new TextField();
    _etiquette.selectable = false;
    _etiquette.autoSize = TextFieldAutoSize.LEFT;
    _etiquette.defaultTextFormat = new TextFormat('arial', 10, 0x000000);
    _etiquette.embedFonts = true;
    addChild(_etiquette);

    // Effets spéciaux
    filters = [
        // Ombre
        new DropShadowFilter(3, 45, 0, 0.3, 6, 6, 1, 2),
        // Volume (ombre intérieure)
        new DropShadowFilter( -10, 45, 0, 0.3, 6, 6, 1, 2, true)
    ];

    // Vecteur géométrique à deux dimensions représentant le déplacement de la bille pour chaque frame
    _velocite = new Vecteur2D();

    // Autres attributs
    _couleur = couleur;
    _numero = numero;
    this.taille = taille;
    _masse = masse;
}

Dessin de la bille

Nous avons à notre disposition une méthode _dessiner héritée de la classe mère Visuel qui, pour rappel, sera appelée dès qu'il faudra redessiner l'objet graphique. Nous allons donc redéfinir cette méthode pour dessiner notre bille :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
override protected function _dessiner():void
{
    super._dessiner();

    // Bille
    graphics.clear();
    graphics.lineStyle(1, _couleur);
    graphics.beginFill(_couleur);
    graphics.drawCircle(0, 0, taille * 0.5 - 1);

    // Bandes
    if (_numero >= 9)
    {
        // Rectangles blancs
        graphics.lineStyle();
        graphics.beginFill(0xffffff);
        graphics.drawRect( -taille / 2, -taille / 2 , taille, 3);
        graphics.drawRect( -taille / 2, taille / 2 - 3, taille, 3);
        graphics.endFill();

        // Masque circulaire
        _masque = new Shape();
        _masque.graphics.beginFill(0);
        _masque.graphics.drawCircle(0, 0, taille * 0.5 - 1);
        addChild(_masque);
        mask = _masque;
    }

    // Numéro
    _etiquette.visible = (_numero > 0);

    if (_etiquette.visible)
    {
        _etiquette.text = _numero.toString();
        _etiquette.x = -_etiquette.width * 0.5;
        _etiquette.y = -_etiquette.height * 0.5;

        graphics.beginFill(0xffffff);
        graphics.drawCircle(0, 0, _etiquette.height * 0.4);
    }
}

Les bandes ne sont dessinées que si le numéro de la bille est supérieur ou égal à 9 : on utilise alors un masque circulaire et deux rectangles blancs.
Puis, nous cachons l'étiquette si le numéro vaut zéro (correspondant alors à la bille blanche). Dans le cas contraire, nous mettons à jour l'étiquette, la centrons au milieu de la bille et dessinons un disque blanc supplémentaire.

Collision avec une autre bille

La gestion des collisions avec les autres billes s'effectue en deux étapes :

  • On teste d'abord la collision entre les deux billes.
  • Si les deux billes sont entrées en collision, on calcule leur nouveau vecteur de déplacement suite au choc élastique.

Commençons donc par créer une méthode testant la collision de la bille avec une autre :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private function _dectecterCollision(bille:Bille):Boolean
{
    var dX:Number = this.x - bille.x;
    var dY:Number = this.y - bille.y;

    var racineRayons:Number = (this.taille + bille.taille) * 0.5;
    racineRayons = racineRayons * racineRayons;

    var racineDistance:Number = (dX * dX) + (dY * dY);

    return racineDistance <= racineRayons;
}

Pour savoir si deux billes sont entrées en collision, nous calculons la distance entre les deux au carré, puis nous la comparons au carré de la somme de leur rayon, comme nous l'avons vu dans le chapitre sur la théorie des collisions (et plus précisément la partie sur les collisions circulaires).

Ensuite, il faut créer une méthode qui va calculer les changements des vecteurs de déplacement lors d'un choc élastique entre deux billes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
private function _calculerChocElastique(bille:Bille):void
{
    var taille:Number = (this.taille + bille.taille) / 2;

    // Repositionnement au point de contact
    // Vecteur distance entre les boules
    var delta:Vecteur2D = new Vecteur2D(this.x - bille.x, this.y - bille.y);
    var d:Number = delta.module;
    // Distance de translation minimum pour pousser les boules
    if (d == 0)
    {
        d = (this.taille + bille.taille) * 0.5 - 1;
        delta = new Vecteur2D((this.taille + bille.taille) * 0.5, 0);
    }
    var mtd:Vecteur2D = delta.multiplier(((this.taille + bille.taille) * 0.5 - d) / d);

    // Masse inverse
    var im1:Number = 1 / this.masse;
    var im2:Number = 1 / bille.masse;

    // Repousser les balles pour éviter qu'elles se chevauchent
    var correction:Vecteur2D = mtd.multiplier(im1 / (im1 + im2));
    this.x += correction.x;
    this.y += correction.y;
    bille.x -= correction.x;
    bille.y -= correction.y;

    // Calcul de la base orthonormée (n,g)
    // n est perpendiculaire au plan de collision, g est tangent
    var nx:Number = (bille.x - this.x) / taille;
    var ny:Number = (bille.y - this.y) / taille;
    var gx:Number = -ny;
    var gy:Number = nx;

    // Calcul des vitesses dans cette base
    var v1n:Number = nx * this.velocite.x + ny * this.velocite.y;
    var v1g:Number = gx * this.velocite.x + gy * this.velocite.y;
    var v2n:Number = nx * bille.velocite.x + ny * bille.velocite.y;
    var v2g:Number = gx * bille.velocite.x + gy * bille.velocite.y;

    // Permute les coordonnées n et conserve la vitesse tangentielle
    // Exécute la transformation inverse (base orthonormée => matrice transposée)
    this.velocite.x = nx * v2n + gx * v1g;
    this.velocite.y = ny * v2n + gy * v1g;
    bille.velocite.x = nx * v1n + gx * v2g;
    bille.velocite.y = ny * v1n + gy * v2g;
}

Il s'agit du calcul que je vous avais donné à la préparation du TP qui permet de définir de nouveaux vecteurs géométriques de déplacement pour la bille courante et la bille testée à l'aide de la théorie des chocs élastiques.

Maintenant que nous avons ces deux méthodes de prêtes, nous pouvons créer la méthode publique gérant la collision de la bille avec une autre bille :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/**
 * Teste la collision avec une autre bille, et applique le choc élastique le cas échéant.
 * @param bille
 */
public function testerCollision(bille:Bille):void
{
    if (_dectecterCollision(bille))
    {
        _calculerChocElastique(bille);
    }
}

Il est important de bien séparer chaque fonctionnalité dans des fonctions différentes : ici, nous avons créé deux méthodes privées qui serons utilisées dans la méthode principale au lieu de tout mélanger dans une seule méthode : le test de collision puis le calcul du choc élastique. Cela permet de rendre le code plus clair, mais cela permet aussi au développeur de tester chaque fonctionnalité plus finement et donc de cerner les problèmes plus facilement.

Déplacement

Enfin, il nous faut une méthode pour déplacer la bille d'une frame. J'ai ajouté un paramètre multiplicateur qui permet de faire varier la vitesse de la simulation : il nous est alors possible de faire un magnifique ralenti ou au contraire, d'accélérer le jeu !

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
/**
 * Déplace la bille d'une frame avec application de la décélération.
 * @param multiplieur
 */
public function deplacer(multiplicateur:Number = 1):void
{
    x += _velocite.x * multiplicateur;
    y += _velocite.y * multiplicateur;

    rotation += _velocite.module * 3 * multiplicateur;

    if (_velocite.module > 0.03)
    {
        _velocite.module -= (_velocite.module * Constantes.frottement + 0.03) * multiplicateur;
    }
    else
    {
        _velocite.module = 0;
    }
}

Nous commençons par déplacer la bille en fonction de son vecteur de déplacement velocite, puis nous la faisons tourner sur elle-même plus ou moins rapidement en fonction de sa vitesse de déplacement (c'est-à-dire le module du vecteur de déplacement).
Ensuite, nous diminuons la vitesse de la bille d'au moins 0.03 pixels par frame (fois le multiplicateur) en fonction de la constante de frottement que nous avons défini au début. Ceci nous permet de simuler la décélération de la bille sur la table.

Le minimum de 0.03 permet d'éviter des comportements bizarres de la bille lors de la décélération en imposant une diminution minimale et un arrêt si la vitesse est trop petite (inférieure à 0.03).

Table

La table contient deux dessins (table et zone de jeu) et gère la collision des billes avec les bords de la zone de jeu.
Ajoutons d'abord les deux attributs de la table (l'image de fond et le dessin de la zone de jeu) :

1
2
3
// Eléments visuels
private var _fond:Shape;
private var _dessin:Shape;

Constructeur

Le constructeur sert une fois de plus à créer les objets d'affichages qui composent notre objet. Il s'agit ici du dessin de la table et du dessin de la zone de jeu :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public function Table() 
{
    _fond = new Shape();
    // Ombre portée
    _fond.filters = [new DropShadowFilter(10, 45, 0, 0.6, 10, 10, 1, 2)];
    addChild(_fond);

    _dessin = new Shape();
    // Ombre intérieure
    _dessin.filters = [new DropShadowFilter(4, 45, 0, 0.3, 20, 20, 1, 2, true)];
    addChild(_dessin);
}

Dessin de la table

Comme pour la classe Bille, nous redéfinissions la méthode _dessiner de la classe mère Visuel pour dessiner la table :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
override protected function _dessiner():void 
{
    super._dessiner();

    // Fond
    var margeFond:Number = 0.08 * Unite.METRE;
    _fond.graphics.clear();
    _fond.graphics.lineStyle(5, 0x432e0e, 1, true);
    _fond.graphics.beginFill(0x0c351e);
    _fond.graphics.drawRoundRect( -margeFond, -margeFond, width + 2 * margeFond, height + 2 * margeFond, 32);

    // Zone de jeu
    _dessin.graphics.clear();
    _dessin.graphics.beginFill(0x006029);
    _dessin.graphics.drawRoundRect(0, 0, width, height, 10);
    _dessin.graphics.endFill();

    // Lignes et points centraux
    for (var i:int = 1; i <= 3; i++)
    {
        // Ligne
        _dessin.graphics.lineStyle(4, 0xffffff, 0.15);
        _dessin.graphics.moveTo(width * 0.25 * i, 0);
        _dessin.graphics.lineTo(width * 0.25 * i, height);

        // Point
        _dessin.graphics.lineStyle();
        _dessin.graphics.beginFill(0xffffff, 0.3);
        _dessin.graphics.drawCircle(width * 0.25 * i, height * 0.5, 3);
    }
}

La taille définie grâce à width et height correspond à la taille de la zone de jeu pour faciliter l'écriture du code. La taille totale de la table (en comptant les bordures) est donc plus grande.

Collision avec les billes

Intéressons nous maintenant à la collision des billes avec les bords de la table. Il s'agit d'un cas dont nous avons parlé dans le chapitre sur la théorie des collisions à travers de l'exemple de la collision avec la scène principale. Cette-fois, il s'agit d'une zone plus réduite représentée par la zone de jeu.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
 * Teste la collision de la bille avec les bords de la zone de jeu et applique la collision le cas échéant.
 * @param bille
 */
public function testerCollision(bille:Bille):void
{
    var taille:Number = bille.taille * 0.5;
    // Bord gauche
    if (bille.x - taille <= 0)
    {
        bille.x = taille;
        bille.velocite.x *= -1;
    }
    // Bord droit
    else if (bille.x + taille >= width)
    {
        bille.x = width - taille;
        bille.velocite.x *= -1;
    }

    // Bord haut
    if (bille.y - taille <= 0)
    {
        bille.y = taille;
        bille.velocite.y *= -1;
    }
    // Bord bas
    else if (bille.y + taille >= height)
    {
        bille.y = height - taille;
        bille.velocite.y *= -1;
    }
}

Trous

Chaque trou contient un dessin très simple et permet de gérer la collision avec les billes. Ce n'était pas demandé, mais j'ai ajouté en plus une poche pour afficher les billes qui sont tombées dans chaque trou :

Poche contenant les billes tombées dans le trou

C'est quand-même plus agréable, non ? Mais ne vous inquiètez pas si vous n'avez pas fait quelque chose de similaire, il s'agit d'un bonus. ;)

Attributs

Au niveau des attributs, nous avons le numéro du trou, la liste des billes tombées dans le trou, le dessin du trou et enfin le dessin de la poche :

1
2
3
4
5
6
7
// Infos de base
private var _numero:int;
// Billes tombées dans le trou
private var _billes:Vector.<Bille>;
// Eléments visuels
private var _dessin:Shape;
private var _poche:Shape;

Voici les numéros de nos trous :

Numéro des trous

Les trous dont le numéro est inférieur ou égal à 3 sont en haut et les autres sont en bas. Cette information sera utile pour dessiner les poches des billes.

Vous pouvez ajouter des accesseurs pour chaque attribut si vous jugez cela nécessaire.

Constructeur

Le constructeur sert encore à initialiser les objets de la classe. Ici, nous créons la liste des billes, puis ajoutons les différents objets d'affichage (dessin du trou et de la poche), et enfin affectons le paramètre numéro :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public function Trou(numero:int) 
{
    super();

    _billes = new Vector.<Bille>();

    _dessin = new Shape();
    addChild(_dessin);

    _poche = new Shape();
    _poche.alpha = 0;
    // Ombre intérieure de la poche
    _poche.filters = [new GlowFilter(0x000000, 0.5, 6, 6, 2, 2, true)];
    addChild(_poche);

    _numero = numero;
}

Dessin du trou

Encore une fois, nous redéfinissons la méthode _dessiner :

1
2
3
4
5
6
7
8
9
override protected function _dessiner():void 
{
    super._dessiner();

    // Trou
    _dessin.graphics.clear();
    _dessin.graphics.beginFill(0);
    _dessin.graphics.drawCircle(0, 0, taille * 0.5);
}

Test de la collision des billes avec le trou

Pour la gestion des collisions avec les billes, nous avons besoin que d'une méthode testant la collision d'une bille avec le trou :

1
2
3
4
5
6
7
8
9
/**
 * Teste la collision de la bille avec le trou courant.
 * @param bille
 * @return      Vrai si la bille est entrée en collision avec le trou.
 */
public function testerCollision(bille:Bille):Boolean
{
    return _dessin.hitTestObject(bille);
}

Cette méthode permettra au billard de savoir si une bille est tombée dans un trou.

Ajout d'une bille tombée dans le trou

Dans le cas d'une collision, la bille sera ajouté au trou par le jeu de billard pour être affichée dans la poche. Il nous faut alors cette méthode qui ajoute une bille :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/**
 * Ajoute une bille dans le trou. Elle sera affichée dans une poche.
 * @param bille
 */
public function ajouterBille(bille:Bille):void
{
    // Index de la bille dans la liste des billes tombées dans le trou
    var index:int = _billes.length;

    // Bille
    bille.alpha = 1;
    bille.rotation = 0;
    bille.x = x + ((index % 5) - 2) * bille.taille;
    bille.y = y + Math.floor(index / 5) * bille.taille;
    _billes.push(bille);

    // Ajustement de la position de la bille en fonction de l'enplacement de la poche
    if (numero <= 3)
    {
        // Poche au dessus du trou
        bille.y -= bille.taille * 6;
    }
    else
    {
        // Poche en dessous du trou
        bille.y += bille.taille * 4;
    }

    // Animation bille
    TweenMax.fromTo(bille, 0.8, { scaleX:0, scaleY:0 }, { scaleX:1, scaleY:1, ease:Bounce.easeOut, delay:0.3 } );

    // Poche
    _poche.graphics.clear();
    _poche.graphics.beginFill(0x888888, 1);
    _poche.graphics.drawRoundRect(0, 0, 5 * bille.taille + 20, 3 * bille.taille + 20, 20);
    _poche.x = -_poche.width * 0.5;
    _poche.y = -_poche.height;

    // Dessin du triangle de la poche (façon bulle de BD)
    if (numero <= 3)
    {
        // Triangle en bas de la bulle
        _poche.y -= bille.taille * 3;
        _poche.graphics.moveTo(_poche.width * 0.5 - bille.taille, _poche.height);
        _poche.graphics.lineTo(_poche.width * 0.5 + bille.taille, _poche.height);
        _poche.graphics.lineTo(_poche.width * 0.5, _poche.height + bille.taille);
        _poche.graphics.endFill();
    }
    else
    {
        // Tirangle en haut de la bulle
        _poche.y += bille.taille * 3 + _poche.height;
        _poche.graphics.moveTo(_poche.width * 0.5 - bille.taille, 0);
        _poche.graphics.lineTo(_poche.width * 0.5 + bille.taille, 0);
        _poche.graphics.lineTo(_poche.width * 0.5, - bille.taille);
        _poche.graphics.endFill();
    }

    // Animation poche
    TweenMax.to(_poche, 0.25, { alpha:1 } );
}

Vous remarquerez que je dessine la poche ici au lieu de la dessiner dans la méthode _dessiner : en effet, nous avons besoin de la taille des billes pour faire une bulle pouvant contenir au moins 15 billes. La poche doit faire donc une taille supérieure à 5 billes en largeur et 3 billes en hauteur.

Retrait des billes tombées dans le trou

Lorsque l'on recommence la partie, il nous faut enlever les billes dans chaque trou, grâce à cette méthode :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/**
 * Retire les billes du trou.
 */
public function enleverBilles():void
{
    _billes = new Vector.<Bille>();

    // On cache la poche
    TweenMax.to(_poche, 0.25, { alpha:0, onComplete:_poche.graphics.clear } );
}

Queue

La classe de la queue de billard est plustôt simple a créer par rapport aux autres classes, on n'a pas de collision à gérer. Ici, il ne s'agit que d'affichage.

Commençons par les attributs de la classe :

1
2
3
4
5
6
7
8
// Infos de base
private var _distance:Number;
private var _puissance:Number;
// Indique si les indicateurs visuels sont actifs
private var _indicateursActifs:Boolean = true;
// Eléments visuels
private var _etiquette:TextField;
private var _dessin:Shape;

Constructeur

Cette fois encore, le constructeur créé les objets d'affichage contenus dans l'objet courant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public function Queue() 
{
    super();

    // Dessin de la queue de billard
    _dessin = new Shape();
    _dessin.filters = [new DropShadowFilter(12, 45, 0, 0.3, 6, 6, 1, 2)];
    addChild(_dessin);

    // Etiquette indiquant la puissance du coup
    _etiquette = new TextField();
    _etiquette.autoSize = TextFieldAutoSize.LEFT;
    _etiquette.embedFonts = true;
    _etiquette.selectable = false;
    _etiquette.defaultTextFormat = new TextFormat('arial', 22, 0xffffff, true);
    _etiquette.filters = [new GlowFilter(0x000000, 0.5, 2, 2, 2, 1), new DropShadowFilter(12, 45, 0, 0.3, 6, 6, 1, 2)];
    addChild(_etiquette);
}

Dessin de la queue de billard

Surprise ! Nous redéfinissons encore la méthode _dessiner pour le dessin de notre queue de billard ! ^^ L'étiquette indiquant la puissance du coup ne sera pas modifiée ici, mais plutôt dans une autre méthode. En effet, il est inutile (voire non-recommandé) de lier les deux opérations : la queue serait alors redessinée à chaque fois que le joueur bougera la souris pour modifier la puissance du coup. Nous nous contenterons ici de cacher l'étiquette si les indicateurs visuels sont désactivés.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
override protected function _dessiner():void 
{
    super._dessiner();

    // Queue
    _dessin.graphics.clear();
    _dessin.graphics.beginFill(0x000000);
    _dessin.graphics.drawRect(-width, -height * 0.5, width * 0.4, height);
    _dessin.graphics.beginFill(0x3e2317);
    _dessin.graphics.drawRect( -width * 0.6, -height * 0.6 * 0.5, width * 0.6, height * 0.6);

    // Viseur
    if (_indicateursActifs)
    {
        graphics.clear();
        graphics.lineStyle(3, 0xffffff, 0.5);
        graphics.moveTo(25, 0);
        graphics.lineTo(45, 0);
    }

    // Etiquette de puissance
    _etiquette.visible = _indicateursActifs;
}

Créons donc une autre méthode pour mettre à jour l'étiquette indiquant la puissance du coup :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
private function _majEtiquette():void
{
    _etiquette.text = Math.round(_puissance * 100) + '%';

    if (rotation < - 90 || rotation > 90)
    {
        _etiquette.rotation = 180;
        _etiquette.x = - _distance;
        _etiquette.y = _etiquette.height;
    }
    else
    {
        _etiquette.rotation = 0;
        _etiquette.x = - _distance - _etiquette.width;
        _etiquette.y = - _etiquette.height;
    }
}

Pourquoi distinger ici deux cas de rotation ?

Cela permet d'éviter que l'étiquette soit retournée si l'on tourne la queue dans l'autre sens. Lorsque la queue est à gauche de la bille de tir, tout va bien. L'étiquette est affiché dans le bon sens pour l'utilisateur :

Queue à gauche de la bille de tir

Mais si on tourne la queue du côté droit vis-à-vis de la bille, notre étiquette vas ce retrouver à l'envers ! Et ce n'est pas très confortable à lire :

A droite, l'étiquette est à l'envers.

Il faut alors inverser l'étiquette si la queue est à droite de la bille de tir, c'est-à-dire si la rotation de la queue est inférieure à -90 degrés ou supérieure à 90 degrés :

Rotation à 0° de la queue

Rotation à 135° de la queue

Nous avons alors un résultat satisfaisant :

L'étiquette est dans le bon sens !

Accesseurs

Enfin, ajoutons quelques accesseurs pour permettre la modification des données de la queue de billard :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
 * Distance du bout de la queue par rapport à la bille de tir.
 */
public function get distance():Number 
{
    return _distance;
}
public function set distance(value:Number):void 
{
    _distance = value;

    // On déplace le dessin de la queue en fonction de la distance par rapport à la bille de tir
    _dessin.x = -_distance;
}
/**
 * Puissance du tir délivré par la queue.
 */
public function get puissance():Number 
{
    return _puissance;
}
public function set puissance(value:Number):void 
{
    _puissance = value;

    _majEtiquette();
}
/**
 * Indique si les indicateurs visuels sont visibles ou non.
 */
public function get indicateursActifs():Boolean 
{
    return _indicateursActifs;
}
public function set indicateursActifs(value:Boolean):void 
{
    _indicateursActifs = value;

    _dessiner();
}

Billard

Cette classe est la plus complexe de l'animation : c'est le coeur de notre jeu de billard.

Commençons par écrire les différents attributs de la classe :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// Eléments visuels
private var _table:Table;
private var _billes:Vector.<Bille>;
private var _billeDeTir:Bille;
private var _queue:Queue;
private var _trous:Vector.<Trou>;
// Configuration de la queue de billard
private var _distanceMinimum:Number = 25;
private var _distanceMaximum:Number = 250;
private var _puissanceMinimum:Number = 1;
private var _puissanceMaximum:Number = 35;
// Distance de la souris par rapport à la pointe de la queue
private var _distanceSouris:Number = 200;
// Vitesse actuelle de l'animation (1 = vitesse normale)
private var _vitesseAnimation:Number = 1;
// Indicateurs
private var _simulationEnCours:Boolean = false;
private var _phaseDeTir:Boolean = false;
// Indique si le billard est en train d'être déplacé
private var _tableDrag:Boolean = false;

Constructeur

Le constructeur de la classe va une fois de plus nous servir à crééer les objets nécessaires au bon fonctionnement de l'objet courant (ici, le jeu de billard).

Commençons par ajouter la table de billard :

1
2
3
4
5
6
// Table
_table = new Table();
// Taille standard
_table.width = 2.34 * Unite.METRE;
_table.height = 1.17 * Unite.METRE;
addChild(_table);

Puis, continuons sur les six trous de la table :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Trous
_trous = new Vector.<Trou>();
// Marge entre les trous et les bords de la table
var margeTrou:Number = 0.015 * Unite.METRE;
var i:int;
for (i = 0; i < 6; i++)
{
    var trou:Trou = new Trou(i + 1);
    trou.taille = 0.069 * Unite.METRE;
    // Lignes de trois colonnes de trous
    trou.x = (i % 3) * (_table.width + 2 * margeTrou) / 2 - margeTrou;
    trou.y = Math.floor(i / 3) * (_table.height + 2 * margeTrou) - margeTrou;
    _table.addChild(trou);
    _trous.push(trou);
}

Pour rappel, l'opérateur % est le modulo, qui permet d'obtenir le reste de la division.

Attaquons-nous maintenant aux billes. D'abord, spécifions les différents couleurs possibles :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// Billes
// Couleurs réglementaires
var couleurs:Array = [
    0xffffff, // Bille blanche
    0xfac92e, 
    0x1744c1, 
    0xee372e, 
    0x271d53, 
    0xff8d59, 
    0x189e66, 
    0x931422, 
    0x000000, // Bille 8
    0xf7af1d, 
    0x2059e8, 
    0xde291d, 
    0x2f2665, 
    0xfb8351, 
    0x0d955c, 
    0x92121d
];

Puis, créons les 16 billes dont nous avons besoin pour notre billard :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Liste des billes
_billes = new Vector.<Bille>();
// Création des 15 billes
for (i = 0; i <= 15; i++)
{
    var bille:Bille = new Bille(couleurs[i], i, 0.05 * Unite.METRE, 172 * Unite.GRAMME);
    _table.addChild(bille);
    _billes.push(bille);

    if (i == 0)
    {
        _billeDeTir = bille;
    }
}

Etant donné que nous allons déplacer la première bille (bille de tir) lors de la phase 1 du déroulement du jeu, il est bon de la remettre au premier plan afin qu'elle ne se retrouve pas affichée derrière les autres billes :

1
2
// On remet la bille de tir au premier plan
_table.setChildIndex(_billeDeTir, _table.numChildren - 1);

Occupons-nous maintenant de la queue de billard :

1
2
3
4
5
// Queue
_queue = new Queue();
_queue.width = 1.5 * Unite.METRE;
_queue.height = 0.025 * Unite.METRE;
_table.addChild(_queue);

Puis, ajoutons un écouteur d'événement qui nous permet d'attendre que l'objet soit ajouté à la scène principale afin d'avoir accès à la propriété stage :

1
2
// Lorsque le billard est ajouté à la scène, nous ajoutons les autres écouteurs d'événements
addEventListener(Event.ADDED_TO_STAGE, onAddedToStage);

L'écouteur de cet événement ajoute des écouteurs pour gérer la souris :

1
2
3
4
5
6
7
8
9
private function onAddedToStage(event:Event):void
{
    // Déplacement de la table de billard
    stage.addEventListener(MouseEvent.RIGHT_MOUSE_DOWN, onMouseRightDown);
    stage.addEventListener(MouseEvent.RIGHT_MOUSE_UP, onMouseRightUp);

    // Ralenti
    stage.addEventListener(MouseEvent.MIDDLE_CLICK, onMiddleClick);
}

Ainsi, lorsque le bouton droit de la souris est enfoncé, il faut déplacer le billard. Par contre, il faut le faire uniquement si l'on n'est pas en train de placer la bille de tir, c'est-à-dire si on est en phase de tir ou que la simulation est en cours.

1
2
3
4
5
6
7
8
9
/* Déplacement de la table de billard */
private function onMouseRightDown(event:MouseEvent):void
{
    if (_phaseDeTir || _simulationEnCours)
    {
        _table.startDrag();
        _tableDrag = true;
    }
}

Une fois relaché, on arrête le déplacement de la table de billard si elle est en cours de déplacement :

1
2
3
4
5
6
7
private function onMouseRightUp(event:MouseEvent):void
{
    if (_tableDrag)
    {
        _table.stopDrag();
    }
}

Enfin, si on appuie sur la molette de la souris, on active ou désactive le ralenti (qui n'était pas demandé, c'est un bonus) :

1
2
3
4
5
/* Ralenti */
private function onMiddleClick(event:MouseEvent):void
{
    ralenti();
}

Avec une méthode publique permettant d'activer ou désactiver le ralenti en modifiant l'attribut _vitesseAnimation :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
/**
 * Active ou désactive le ralenti.
 */
public function ralenti():void
{
    if (_vitesseAnimation != 0.15)
    {
        _vitesseAnimation = 0.15; // Ralenti à 15% de la vitesse normale !
    }
    else
    {
        _vitesseAnimation = 1;
    }
}

Phase 1 : Préparation

Il est tant d'implémenter les quatres phases du déroulement d'une partie. Commençons par la première : la préparation de la partie. Dans un premier temps, il faut vider les trous au cas où des billes seraient tombées dedans. Ensuite, nous plaçons les billes pour former le triangle initial :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
private function _placerBilles():void
{
    // Vider les trous
    for each(var trou:Trou in _trous)
    {
        trou.enleverBilles();
    }

    // Triangle
    var bille:Bille;
    var l:int = _billes.length;
    var triangleLignes:int = 1;
    var triangleLigne:int = 0;
    var triangleX:Number = _table.width * 0.75 - _billeDeTir.taille * 2.5;
    var order:Array = [0, 9, 7, 12, 15, 8, 1, 6, 10, 3, 14, 11, 2, 13, 4, 5];
    for (var i:int = 0; i < l; i++)
    {
        bille = _billes[order[i]];

        // Initialisation des billes
        bille.alpha = 1;
        bille.scaleX = 1;
        bille.scaleY = 1;
        bille.velocite.x = 0;
        bille.velocite.y = 0;

        bille.horsJeu = false;
        if (i > 0)
        {
            bille.x = triangleX;
            bille.y = _table.height * 0.5 + (triangleLigne - triangleLignes * 0.5 ) * _billeDeTir.taille + _billeDeTir.taille * 0.5;

            triangleLigne ++;
            if (triangleLigne == triangleLignes)
            {
                triangleLigne = 0;
                triangleLignes ++;
                triangleX += _billeDeTir.taille;
            }
        }
    }
}

Phase 2 : Placement de la bille de tir

Pour la phase suivante, il faut laisser au choix du joueur le placement de la bille de tir, mais uniquement dans la zone de jeu. Pour cela, nous utilisons la méthode startDrag qui nous permet de coller la bille de tir à la souris tout en la restreignant à une zone bien précise :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private function _lancerPhaseBilleDeTir():void
{
    _arreterPhaseDeTir();
    _arreterPhaseSimulation();

    _table.stopDrag();
    _tableDrag = false;

    // On annule d'éventuelles animations sur les propriétés de la bille de tir pour éviter les conflits avec le "drag"
    TweenMax.killTweensOf(_billeDeTir);

    // Réinitialisation de la bille
    _billeDeTir.horsJeu = false;
    _billeDeTir.scaleX = 1;
    _billeDeTir.scaleY = 1;
    _billeDeTir.velocite.x = 0;
    _billeDeTir.velocite.y = 0;

    // On fait réapparaître la bille de tir
    TweenMax.to(_billeDeTir, 0.2, { alpha:1 } );

    // On active le "drag" :
    // Lorsque la souris du joueur va se déplacer, la bille de tir se positionnera en dessous, dans les limites de la zone de jeu.
    _billeDeTir.startDrag(true, new Rectangle(_billeDeTir.taille * 0.5, _billeDeTir.taille * 0.5, _table.width - _billeDeTir.taille, _table.height - _billeDeTir.taille));

    addEventListener(MouseEvent.MOUSE_UP, onPhaseBilleDeTirClick);
}

La zone de jeu est définie par la taille de la table.

Lorsque l'utilisateur effectue un clic de souris, nous passons à la phase suivante (phase de tir) dans un écouteur d'événement de souris :

1
2
3
4
5
6
7
private function onPhaseBilleDeTirClick(event:MouseEvent):void
{
    // On empêche la propagation de l'événement pour éviter que le tir ne se déclenche tout de suite
    event.stopImmediatePropagation();

    _lancerPhaseDeTir();
}

Il faut empêcher l'événement du clic de se propager dans les éléments graphiques parents (voir le flux d'événement dans le chapitre sur les événements) : car effectivement, la méthode _lancerPhaseDeTir va ajouter un écouteur sur le même événement MouseEvent.MOUSE_UP, ce qui va le déclencher également et tirer sur la bille blanche tout de suite. A l'aide de la méthode stopImmediatePropagation de l'objet event, nous nous assurons que d'autres écouteurs ne seront pas appellés par la suite.

Enfin, il nous faut une méthode pour terminer la phase de placement de la bille blanche, qui se charge de lâcher la bille et de supprimer l'écouteur de souris que nous avons créé pour cette phase :

1
2
3
4
5
6
private function _arreterPhaseBilleDeTir():void
{
    _billeDeTir.stopDrag();

    removeEventListener(MouseEvent.MOUSE_UP, onPhaseBilleDeTirClick);
}

Phase 3 : Tir avec la queue de billard

Nous arrivons à cette phase de jeu après la phase deux ou si les billes ne bougent plus.

Créons une méthode pour lancer la phase :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
private function _lancerPhaseDeTir():void
{
    _arreterPhaseBilleDeTir();
    _arreterPhaseSimulation();

    // Gestion de la souris
    stage.addEventListener(MouseEvent.MOUSE_MOVE, onPhaseDeTirMouseMove);
    stage.addEventListener(MouseEvent.MOUSE_UP, onPhaseDeTirClick);

    _phaseDeTir = true;
    _queue.indicateursActifs = true;
    _queue.visible = true;

    _deplacerQueue();
}

Dans un premier temps, nous arrêtons les autres phases, puis nous ajoutons les écouteurs dont nous avons besoin.
Enfin, nous préparons la queue de billard à être affichée.

Voici les fonctions écouteurs que nous allons utiliser pour cette phase de jeu :

1
2
3
4
5
6
7
8
private function onPhaseDeTirMouseMove(event:MouseEvent):void
{
    _deplacerQueue();
}
private function onPhaseDeTirClick(event:MouseEvent):void
{
    _tirer();
}

Lorsque l'utilisateur déplace la souris, il faut mettre à jour la queue de billard, et, lorsque qu'il clique, il faut tirer sur la bille blanche.

Comme pour la deuxième phase, il nous faut une méthode pour arrêter la phase de tir qui se charge de supprimer les écouteurs d'événements :

1
2
3
4
5
6
7
8
9
private function _arreterPhaseDeTir():void
{
    // Fin de la gestion de la souris
    stage.removeEventListener(MouseEvent.MOUSE_MOVE, onPhaseDeTirMouseMove);
    stage.removeEventListener(MouseEvent.MOUSE_UP, onPhaseDeTirClick);

    _phaseDeTir = false;
    _queue.visible = false;
}

Lorsque nous bougeons la souris, la queue doit tourner autour de la bille blanche et changer la puissance du tir en fonction de la distance entre la souris et la bille. Pour cela, nous créons cette méthode :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private function _deplacerQueue():void
{
    // On centre la queue sur la bille de tir
    _queue.x = _billeDeTir.x;
    _queue.y = _billeDeTir.y;

    // Distance entre la souris et la bille de tir
    var distance:Number = Outils.distanceNombres(_table.mouseX, _table.mouseY, _billeDeTir.x, _billeDeTir.y) - _distanceSouris;
    if (distance > _distanceMaximum)
    {
        distance = _distanceMaximum;
    }
    else if (distance < _distanceMinimum)
    {
        distance = _distanceMinimum;
    }

    // Angle entre la souris et la bille de tir (radians)
    var angle:Number = Outils.angleNombres(_table.mouseX, _table.mouseY, _billeDeTir.x, _billeDeTir.y);

    // Aplication des modifications sur la queue de billard
    _queue.rotation = angle / Math.PI * 180;
    _queue.distance = distance - 10;
    _queue.puissance = (distance - _distanceMinimum) / (_distanceMaximum - _distanceMinimum);
}

Il est intéressant de limiter la puissance du coup pour éviter que le tir soit trop fort, en limitant la distance entre la queue et la bille de tir à l'aide des attributs _distanceMinimum et _distanceMaximum.

Décortiquons un peu la méthode : nous commençons par placer la queue sur la bille de tir, puis nous calculons la distance entre la pointe de la queue et la bille (distance déterminée par la position de la souris de l'utilisateur). Nous vérifions que cette distance est bien dans les limites autorisées, puis nous calculons l'angle entre la souris et la bille de tir pour faire tourner la queue de billard dans la direction appropriée. Enfin, nous mettons à jour les attributs de la queue de billard avec les résultats de nos calculs.

La dernière méthode de cette phase de jeu sert à tirer dans la bille blanche :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private function _tirer():void
{
    // On arrête la phase de tir
    _arreterPhaseDeTir();
    _queue.indicateursActifs = false;
    _queue.visible = true;

    // Distance entre la souris et la bille de tir
    var distance:Number = Outils.distanceNombres(_table.mouseX, _table.mouseY, _billeDeTir.x, _billeDeTir.y) - _distanceSouris;
    if (distance > _distanceMaximum)
    {
        distance = _distanceMaximum;
    }
    else if (distance < _distanceMinimum)
    {
        distance = _distanceMinimum;
    }

    // Angle entre la souris et la bille de tir (radians)
    var angle:Number = Outils.angleNombres(_table.mouseX, _table.mouseY, _billeDeTir.x, _billeDeTir.y);
    var module:Number = (distance - _distanceMinimum) / (_distanceMaximum - _distanceMinimum) * (_puissanceMaximum - _puissanceMinimum) + _puissanceMinimum;

    // Coup sur la bille
    _billeDeTir.velocite.angle = angle;
    _billeDeTir.velocite.module = module;

    // Animation de la queue
    TweenMax.to(_queue, 0.1, { distance:10, onComplete:_lancerPhaseSimulation } );
}

On arrête d'abord la phase actuelle (phase de tir), puis on calcule la puissance et l'angle du coup qui est porté. Ensuite, on modifie le vecteur de déplacement de la bille blanche pour la faire partir dans la direction et la vitesse souhaitées. Enfin, on anime rapidement la queue de billard pour donner la sensation qu'elle tape dans la bille blanche (ne pas oublier de la rendre visible, car la méthode _arreterPhaseDeTir la cache).

Phase 4 : Simulation des billes

La dernière phase de jeu est la simulation des billes, où l'on doit les déplacer tout en gérant les différentes collisions des billes avec les autres billes, les trous et les bords de la zone de jeu.

Encore une fois, créons une méthode pour démarrer la phase de jeu :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
private function _lancerPhaseSimulation():void
{
    _table.stopDrag();
    _arreterPhaseBilleDeTir();
    _arreterPhaseDeTir();

    addEventListener(Event.ENTER_FRAME, onPhaseSimulationEnterFrame);

    _simulationEnCours = true;
}

On ajoute un écouteur qui va se déclencher pour chaque nouvelle image (par exemple, 30 fois par seconde) et appeler cette méthode :

1
2
3
4
private function onPhaseSimulationEnterFrame(event:Event):void
{
    _deplacerBilles();
}

Ajoutons tout de suite la méthode pour stopper cette phase de jeu (qui supprime l'écouteur d'événement) :

1
2
3
4
5
6
private function _arreterPhaseSimulation():void
{
    removeEventListener(Event.ENTER_FRAME, onPhaseSimulationEnterFrame);

    _simulationEnCours = false;
}

A chaque nouvelle image, il faut donc déplacer toutes les billes encore en jeu en fonction de leur vecteur vitesse et tester toutes les collisions possibles avec les autres billes, les trous et les bords de la table. Créons une méthode pour gérer tout cela :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
private function _deplacerBilles():void
{
    var toutesLesBillesSontImmobiles:Boolean = true;

    // Détection des collisions entre les billes
    var l:int = _billes.length;
    var bille1:Bille, bille2:Bille;
    for (var i:int = 0; i < l; i++)
    {
        bille1 = _billes[i];

        // Si la bille courante est hors-jeu, on passe à la suivante
        if (bille1.horsJeu)
        {
            continue;
        }

        // Si la bille courante se déplace
        if (bille1.velocite.module > 0)
        {
            toutesLesBillesSontImmobiles = false;
        }

        // Tests des collisions de la bille courante avec les autres billes
        for (var j:int = i + 1; j < l; j++)
        {
            bille2 = _billes[j];

            // Bille hors-jeu : on passe à la suivante
            if (bille2.horsJeu)
            {
                continue;
            }

            // Test de collition
            bille1.testerCollision(bille2);
        }

        // Déplacement de la bille courante
        _deplacerBille(bille1);
    }

    // Toutes les billes sont immobiles et la bille de tir n'est pas dans un trou, il faut relancer la phase de tir
    if (toutesLesBillesSontImmobiles)
    {
        if (_billeDeTir.horsJeu)
        {
            setTimeout(_lancerPhaseBilleDeTir, 400); // Laisse du temps pour l'animation de chute de la bille dans le trou
        }
        else
        {
            _lancerPhaseDeTir();
        }
    }
}

A l'aide du booléen toutesLesBillesSontImmobiles, nous déterminons si les billes ne bougent plus : il faut alors soit retourner à la phase 2 (placement de la bille blanche) si la bille blanche est tombée dans un trou, soit retourner à la phase 3 (phase de tir) pour taper à nouveau dans la bille blanche.

Pour déplacer chaque bille, j'ai créé une autre méthode pour séparer un petit peu le code :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
private function _deplacerBille(bille:Bille):void
{
    // Mouvement de la bille (avec multiplicateur pour le ralenti)
    bille.deplacer(_vitesseAnimation);

    // Détection des collisions de la bille avec les trous de la table de billard
    _detecterCollisionTrou(bille);

    // Détextion des collisions de la bille avec les bords de la table
    if (!bille.horsJeu)
    {
        _table.testerCollision(bille);
    }
}

Comme nous pouvons le voir, elle se charge de déplacer la bille d'un cran, puis teste les collisions entre la bille et les trous. Si la bille n'est pas tombée dans un trou, on teste alors les collisions entre la bille et les bords de la table.

La gestion des collisions avec les trous est implémentée dans une autre méthode (encore dans une optique de séparation du code) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private function _detecterCollisionTrou(bille:Bille):void
{
    // On teste la collision de la bille avec chaque trou
    for each(var trou:Trou in _trous)
    {
        if (trou.testerCollision(bille))
        {
            // La bille est sortie -> hors-jeu
            bille.horsJeu = true;

            // Paramètres de l'animation
            var params:Object = { x: trou.x, y: trou.y, scaleX:0.7, scaleY:0.7, alpha:0 };

            if (bille != _billeDeTir)
            {
                // Si la bille n'est pas la bille de tir, on l'ajoute à la réserve du trou à la fin de l'animation
                params.onComplete = trou.ajouterBille;
                params.onCompleteParams = [bille];
            }

            // Animation de chute dans le trou
            TweenMax.to(bille, 0.4, params);
        }
    }
}

Pour chaque trou, on teste s'il y a collision avec la bille. Le cas échéant, la bille est mise hors jeu et est animée pour donner l'impression qu'elle chute dans le trou.

Nouvelle partie

Pour lancer une nouvelle partie, créons une méthode publique nouvellePartie qui se charge d'arrêter les différentes phases de jeu et relance le jeu de billard :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/**
 * Remet le billard à zéro et lance une nouvelle partie.
 */
public function nouvellePartie():void
{
    // Arrêt de l'éventuelle partie ne cours
    _arreterPhaseBilleDeTir();
    _arreterPhaseSimulation();
    _arreterPhaseDeTir();

    // Triangle des billes
    _placerBilles();

    // Bille de tir
    _lancerPhaseBilleDeTir();
}

Accesseurs

Enfin, ajoutons deux définitions des accesseurs de taille du billard afin de ne prendre en compte que la taille de la table (sans la queue de billard). Cela sera notamment utile pour centrer le billard au milieu de l'écran.

1
2
3
4
5
6
7
8
9
/* On remplace le fonctionnement habituel des accesseurs de la taille du billard pour ne prendre en compte que la taille de la table. */
override public function get width():Number 
{
    return _table.width;
}
override public function get height():Number 
{
    return _table.height;
}

Main

Dans notre classe principale, nous allons gérer la création du jeu de billard et l'affichage de la texture de fond de notre application.

Préparatifs

Commençons par configurer la scène pour qu'elle soit alignée en haut à gauche, sans zoom automatique, tout en initialisant nos polices de caractères embarquées :

1
2
3
4
5
// Configuration de la scène
stage.align = StageAlign.TOP_LEFT;
stage.scaleMode = StageScaleMode.NO_SCALE;
// Polices embarquées
EmbedFonts.init();

Voici alors le code minimal notre classe Main :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package 
{
    import flash.display.Sprite;
    import flash.display.StageAlign;
    import flash.display.StageDisplayState;
    import flash.display.StageQuality;
    import flash.display.StageScaleMode;
    import flash.events.Event;

    /**
     * Classe principale
     * @author Guillaume CHAU
     */
    public class Main extends Sprite 
    {
        public function Main():void 
        {
            if (stage) init();
            else addEventListener(Event.ADDED_TO_STAGE, init);
        }

        private function init(e:Event = null):void 
        {
            removeEventListener(Event.ADDED_TO_STAGE, init);
            // entry point

            // Configuration de la scène
            stage.align = StageAlign.TOP_LEFT;
            stage.scaleMode = StageScaleMode.NO_SCALE;
            stage.quality = StageQuality.BEST;

            // Polices embarquées
            EmbedFonts.init();

            // Commencer à coder ici
        }
    }

}

Création de la texture de fond

Attaquons avec la texture tapissant le fond de l'application. Tout d'abord, embarquons l'image dont nous avons besoin et déclarons les attributs nécessaires dans la classe (en dehors de la méthode init) :

1
2
3
4
[Embed(source = "../lib/img/fond.png")]
private var Fond:Class;
private var _fond:Sprite;
private var _texture:BitmapData;

Puis, créons le dessin de fond et la texture dans la méthode init :

1
2
3
4
5
6
// Fond
_fond = new Sprite();
addChild(_fond);
// Texture du fond
var bmp:Bitmap = new Fond();
_texture = bmp.bitmapData;

J'utilise un objet de classe Sprite et non de classe Shape pour qu'il puisse recevoir les clics de souris afin que nous puissions écouter les événements de souris partout sur la scène principale (par exemple, pour déplacer la table avec le clic droit).

Création du billard

Puis, occupons-nous du billard, avec en premier l'ajout d'un attribut dans la classe :

1
private var _billard:Billard;

Ensuite, créons le billard, ajoutons-le à la scène et lançons une nouvelle partie automatiquement :

1
2
3
4
// Billard
_billard = new Billard();
addChild(_billard);
_billard.nouvellePartie();

Position du billard et dessin de la texture de fond

Une fois nos objets créés, il faut s'occuper de leur position et taille sur la scène. Pour cela, ajoutons cette méthode :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
private function _rafraichir():void
{
    // Fond
    _fond.graphics.clear();
    _fond.graphics.beginBitmapFill(_texture);
    _fond.graphics.drawRect(0, 0, stage.stageWidth, stage.stageHeight);

    // Billard
    _billard.x = (stage.stageWidth - _billard.width) * 0.5;
    _billard.y = (stage.stageHeight - _billard.height) * 0.5;
}

La méthode se charge de dessiner le fond de l'application avec un remplissage de l'image de la texture grâce à la méthode beginBitmapFill de la classe Graphics (voir le chapitre sur le dessin avec l'Actionscript 3).
Elle centre également le billard au milieu de la scène pour des raisons de confort de l'utilisateur.

Cette méthode rafraichir doit être appelée une fois au lancement de notre application afin de mettre le fond et le billard en place, puis à chaque fois que la fenêtre de l'application est redimensionnée.

Ainsi, dans le code principal (méthode init), ajoutons un premier appel à cette fonction :

1
2
// On met à jour la position du billard et la texture de fond pour la première fois
_rafraichir();

Gestion du redimensionnement de l'application

Comme nous l'avons vu, il faut écouter le changement de taille de la scène de l'application, afin de redessiner le fond texturé et de recentrer le billard. Créons la fonction qui écoutera l'événement de redimensionnement :

1
2
3
4
private function onStageResize(event:Event):void
{
    _rafraichir();
}

Puis, ajoutons dans le code principal l'écouteur à la scène principale pour se déclencher au redimensionnement (événement Event.RESIZE) :

1
2
// Lorsque la scène est redimensionnée
stage.addEventListener(Event.RESIZE, onStageResize);

Maintenant, si l'utilisateur met l'application en plein écran par exemple, la texture de fond recouvrera correctement la surface de l'écran et le billard sera bien centré. :)

Raccourci clavier

Enfin, ajoutons un raccourci clavier pour lancer une nouvelle partie à tout moment avec la touche espace. Ajoutons un écouteur d'événement clavier sur la scène principale :

1
2
// Gestion du clavier
stage.addEventListener(KeyboardEvent.KEY_UP, onKeyUp);

Puis, écrivons cette fonction écouteur qui lance une nouvelle partie si le code de la touche relâchée par l'utilisateur correspond au code de la touche espace :

1
2
3
4
5
6
7
private function onKeyUp(event:KeyboardEvent):void
{
    if (event.keyCode == Keyboard.SPACE)
    {
        _billard.nouvellePartie();
    }
}

Et voilà ! Nous avons fait le tour de toutes les classes du projet : vous avez pu comparer votre code avec le mien, si vous avez des différences, ne vous inquiétez pas, c'est tout à fait normal ! Il n'existe pas une seule solution pour implémenter notre billard. ;)


Sources du Billard

J'ai préparé une archive contenant l'ensemble du dossier du projet de Billard afin que vous puissiez le télécharger, l'explorer et le compiler par vous-même. Vous aurez alors une bonne vision globale de l'architecture de l'application.

Télécharger le dossier du projet

Idées d'amélioration

Notre billard américain fonctionne, mais il lui manque sûrement quelques fonctionnalités pour constituer un jeu complet.

  • Afficher un menu : la plupart des jeux disposent d'un menu pour effectuer diverses opérations. Ici, nous pourrions imaginer un menu permettant de lancer une nouvelle partie, afficher les règles du jeu de billard américain, une liste des raccourcis clavier et souris, etc.
  • Appliquer plus de règles : le billard américain comporte davantage de règles que nous n'avons pas implémentées dans notre jeu. Par exemple, dès qu'un joueur met la bille 8 dans un trou, il perd et la partie s'arrête !
  • Améliorer l'interface : il serait intéressant d'afficher plus d'interface pour les joueurs comme des compteurs de score, un indicateur de tour, un message indiquant quel joueur doit jouer, etc.
  • Afficher le début de la trajectoire potentielle : pour aider le joueur à viser sur la table de billard, ce qui n'est pas forcément évident sur un ordinateur, nous pourrions afficher des points blancs représentant la trajectoire possible de la bille blanche après le coup.
  • Afficher différentes options : les joueurs aiment bien personnaliser leur jeu en choisissant parmi plusieurs thèmes visuels par exemple ou en jouant en un temps limité.
  • Proposer du billard français : le billard français dispose de ses propres règles car il n'y a pas de trou dans la table et on ne dispose que de trois billes ! On pourrait alors créer une autre classe BillardFrancais et proposer le choix du type de billard au joueur.

N'hésitez pas à améliorer votre billard car comme je l'ai déjà répété : pratiquer, c'est progresser ! ;)