INFO702 - TPs
Loading...
Searching...
No Matches
TP0 Programmation objet et polymorphisme, scène graphique et collisions, librairie Qt

1 - Objectifs, pointeurs utiles

L'objectif de ce TP est de vous familiariser avec la programmation objet classique en C++. L'idée est de partir d'une base de code déjà conséquente et de l'enrichir progressivement. Le focus est sur le polymorphisme (donc les méthodes virtuelles). Le prétexte est de vous faire travailler sur un programme graphique temps réel, qui gère plein d'objets différents dont on devra détecter les collisions.

La généricité se fera donc au travers du polymorphisme. A l'issue de cette séance, vous maîtriserez:

  • la déclaration d'une classe/structure, de ses constructeurs, de ses données membre
  • la définition des méthodes de la classe dans un fichier source
  • la rédéfinition de méthodes virtuelles
  • l'utilisation de certains éléments de la librairie graphique Qt

Les sites suivants pourront être utile pendant le TP:

Warning
Normalement le code fonctionne indifféremment sous Qt5 ou Qt6 (en tous cas sous MacOS).
Note
Il n'y a pas d'IDE recommandé plus particulièrement qu'un autre, mais je constate:
  • un étudiant sous Linux avec un éditeur de texte (vim, emacs, sublime text, gedit) et la compilation via make démarre le TP en 5 minutes.
  • un étudiant avec son IDE déjà bien configuré avant d'arriver en TP gagne 1h sur l'étudiant qui installe son IDE pendant le TP.
  • la plupart des étudiants utilisent CodeBlocks, parfois Eclipse, parfois Visual Studio C++ ou CLion
  • Windows entraîne des problèmes supplémentaires: bibliothèques manquantes, programme exécuté qui ne s'arrête jamais (vérifiez votre antivirus, certains avaient des problèmes avec AVAST), gestion des fichiers bizarre (Windows a deux modes: texte et binaire; il faut donc rajouter le mode ios::binary ou ios::text à l'ouverture des fichiers)
  • Ce [https://github.com/MASTER-UNIV-SMB/INFO702-ENVIRONNEMENT-WINDOWS site], gracieusement écrit par un ancien de la promotion, peut vous aider à installer Qt correctement sur un système Windows.

2 - Prise en main du code, compilation avec Qt

On va donne une archive avec quelques fichiers tout prêts:

  • collider.pro décrit la configuration du projet pour Qt. C'est là qu'on précise les fichiers sources, les exécutables, les dépendances à d'autres bibliothèques, les options de compilation.
  • collider.qrc décrit les ressources propres au projet, ici quelques images. Si vous voulez rajouter des images au projet, listez-les aussi ici.
  • collider.cpp est le programme principal qui initialise l'application Qt et instancie les formes graphiques
  • objects.hpp est le fichier entête de objects.cpp, qui définit quelques constantes ainsi que les classes abstraites et concrètes des formes graphiques.
  • objects.cpp est le fichier source donnant le corps des méthodes déclarées dans objects.hpp

Pour compiler un projet Qt, on utilise qmake, qui va créer un fichier Makefile, puis make. Si vous avez installé Qt dans un répertoire exotique, il faut donne le chemin complet de qmake. Ensuite tout est bien configuré.

$ qmake
$ make
$ ./collider                              # sous Linux
$ ./collider.app/Contents/MacOS/collider  # sous MacOS

Normalement, vous avez un programme qui vous affiche quelque chose du genre ci-dessous:

Application Qt initiale

Prenez le temps de regarder le code fourni (en commençant par collider.cpp, puis objects.hpp and objects.cpp) et éventuellement chercher quelques explications dans la doc Qt [https://doc.qt.io/qt-5 doc Qt5] [https://doc.qt.io/qt-6 doc Qt6].

On notera la hiérarchie suivante pour nos formes graphiques (cf. class GraphicalShape):

Hiérarchie des classes utilisées pour représenter les éléments graphiques

Evidemment, Qt offre déjà pas mal de choses dans la classe QGraphicsItem, mais nous allons faire nos propres formes graphiques avec algorithme randomisé de détection de collisions.

  • la classe GraphicalShape est la classe de base pour nos formes. C'est une classe abstraite, qui hérite de QGraphicsItem et qui définit les méthodes virtuelles pures GraphicalShape::isInside et GraphicalShape::randomPoint.
  • la classe MasterShape hérite de GraphicalShape et définit une forme maître ou principale. C'est elle qui est chargée de détecter les collisions, de vérifier que la forme reste dans les limites de la fenêtre. Elle contient aussi un pointeur vers sa forme graphique fille, qui est l'assemblage des formes graphiques qui la compose.
  • la classe Asteroid hérite de MasterShape et définit un objet astéroïde tout simple, qui se compose d'un seul disque et qui se déplace toujours dans la même direction et à la même vitesse.
  • la classe Disk hérite de GraphicalShape et représente une forme graphique toute simple, un disque de rayon donné centré en (0,0). Notez que cette classe connaît sa MasterShape, ce qui lui permet de changer de couleur si une collision a été détectée.
  • la classe LogicalScene stocke les objets graphiques "maîtres" et fournit les méthodes pour détecter les collisions entre ces objets. Dans le code fourni, 100 points sont testés entre chaque paire d'objets.

3 - Premiers objets graphiques

3.1 - classe Rectangle (graphique)

Vous allez définir deux classes. D'abord une classe Rectangle, qui, sur le modèle de Disk, hérite de GraphicalShape. Elle représente un rectangle quelconque. Pour afficher un rectangle avec Qt, on utilise la méthode QPainter::drawRect. N'oubliez pas de redéfinir toutes les méthodes abstraites (i.e. virtuelles pures) de GraphicalShape.

// A rectangle is a simple graphical shape. It points to its master shape
// in order to know in which color it must be painted.
struct Rectangle : public GraphicalShape
{
Rectangle( QPointF lo, QPointF hi, const MasterShape* master_shape );
virtual void paint( QPainter *painter, const QStyleOptionGraphicsItem *option,
QWidget *widget) override;
virtual QPointF randomPoint() const override;
virtual bool isInside( const QPointF& p ) const override;
virtual QRectF boundingRect() const override;
const QPointF _lo, _hi;
const MasterShape* _master_shape;
};
Abstract class that describes a graphical object with additional methods for testing collisions.
Definition objects.hpp:24
Polymorphic class that represents the top class of any complex shape.
Definition objects.hpp:38

3.2 - classe SpaceTruck

Vous ferez ensuite une classe SpaceTruck, qui hérite de MasterShape, et dont la représentation graphique est un rectangle vert, qui devient jaune lors d'une collision. Au niveau des mouvements, il se déplacera en avant et en tournant à droite en permanence. On utilisera opportunément les méthodes héritées de QGraphicsItem, QGraphicsItem::rotation et QGraphicsItem::setRotation, pour faire ce mouvement dans SpaceTruck::advance.

Voilà un résultat possible avec 10 astéroides, 5 space trucks de taille 100x20.

Astéroïdes et space trucks...
Note
Le constructeur de Spacetruck s'occupe d'instancier le rectangle et d'appeler this->setGraphicalShape sur cet objet graphique.
On observe que la rotation s'applique avant le déplacement.
Pour le déplacement observez le code ci-dessous de Asteroid::advance. Le principe pour avancer tout droit est de faire un point (speed,0) (donc un peu à l'"avant" de la forme qui est centré en (0,0)), et de demander les coordonnées correspondantes dans le repère du parent. Comme on change la position via setPos, ça déplace l'astéroïde.
void
Asteroid::advance(int step)
{
if (!step) return;
setPos( mapToParent( _speed, 0.0 ) ); // avance dans le repère local
setRotation( rotation() + angle ); // si vous voulez tourner, angle != 0
MasterShape::advance( step ); // regarde si la forme a dépassé les bords
}

4 - Objets complexes par assemblage de formes simples

On va fabriquer des formes plus complexes par assemblage de formes simples. Pour ce faire on va créer une forme graphique Union, qui hérite de GraphicalShape, et dont le seul rôle est de grouper deux GraphicalShape. On note les points suivants:

  • dans Union::randomPoint : il faudra tirer une fois sur deux un point dans la forme 1 et un point dans la forme 2. Le plus simple est de tirer un entier aléatoire et d'appeler l'un ou l'autre en fonction de la parité. Une solution plus évoluée est de calculer les aires A1 et A2 des boites englobantes des deux formes, et de demander un point dans la forme 1, si rand01() < A1/(A1+A2, sinon de demander le point dans la forme 2.
  • dans Union::isInside il faudra bien tester si le point appartient à une des formes.
  • dans ‘Union::boundingRect’ on note que Qt donne un operator| sur les QRect pour calculer le rectangle englobant de deux rectangles.
  • dans Union::Union il faut préciser à Qt la hiérarchie d'objets graphiques pour qu'il s'occupe de leur affichage. On utiliser setParentItem ainsi:

    Union::Union( GraphicalShape* f1, GraphicalShape* f2 )
    : _f1( f1 ), _f2( f2 )
    {
    _f1->setParentItem( this );
    _f2->setParentItem( this );
    }
  • la méthode Union::paint(...) est alors définie, mais ne contient pas de code, car Qt (grâce à setParentItem) peut aller chercher tout seul les autres objets graphiques.

On pourra ensuite mettre à jour SpaceTruck pour qu'il soit composé de formes simples différentes.

// In SpaceTruck constructor
Rectangle* d1 = new Rectangle( QPointF( -80, -10 ), QPointF( 0, 10 ), this );
Rectangle* d2 = new Rectangle( QPointF( 10, -10 ), QPointF( 30, 10 ), this );
Rectangle* d3 = new Rectangle( QPointF( 0, -3 ), QPointF( 10, 3 ), this );
Union* u23 = new Union( d2, d3 );
Union* u = new Union( d1, u23 );
// Tells the space truck that it is composed of the union of the previous shapes.
this->setGraphicalShape( u );
De plus jolis space trucks...

5 - Transformations géométriques

Pour composer des formes complexes, il nous reste à nous donner des transformations géométriques pour pouvoir tourner et placer nos formes. On va définir une classe Transformation, qui prendra en paramètre un GraphicalShape f, un vecteur de déplacement dx et un angle de rotation angle:

struct Transformation: public GraphicalShape {
Transformation( GraphicalShape* f, QPointF dx, qreal angle );
...
};
  • dans le constructeur, ne pas oubliez le setParentItem( _f ) puis utiliser this->setPos et this->setRotation pour placer Transformation correctement dans son repère. On mémorisera aussi en donnée membre, à la fois le déplacement dx et l'angle angle, voire même on peut précalculer cos( angle * pi/180 )et sin( angle * pi/180 ).

On veillera ensuite à redéfinir les méthodes usuelles:

  • dans Transformation::randomPoint : on tire le point aléatoire dans la forme f, puis on applique la rotation et enfin la translation.
  • dans Transformation::isInside( p ) : on va dans l'autre sens, on soustrait le déplacement, on applique la rotation inverse et on demande à f si le point obtenu est dedans.
  • dans Transformation::boundingRect il suffit d'utiliser la méthode héritée mapRectToParent qui fait tout le calcul voulu de rotation d'une boite !
  • dans Transformation::paint(...) le code est vide, car Qt (grâce à setParentItem) peut aller chercher tout seul les autres objets graphiques.
Note
On rappelle qu'une rotation de centre \( (0,0) \) et d'angle a (en radian) d'un point \( p=(x,y) \) donne le point \( q=(x',y') \) ainsi:

\( x' = x * \cos(a) - y * \sin(a), \qquad y' = x * \sin(a) + y * \cos(a) \).

On pourra alors s'amuser à faire des objets plus complexes en composant nos formes. On pourra ainsi créer un vaisseau spatial sous la forme d'une classe Enterprise qui hérite de MasterShape. On choisira une couleur grise pour le vaisseau et rouge en cas de collision.

Le code suivant vous place les éléments graphiques dans le constructeur de Enterprise:

Rectangle* r1 = new Rectangle( QPointF( -100, -8 ), QPointF( 0, 8 ), this );
Rectangle* r2 = new Rectangle( QPointF( -100, -8 ), QPointF( 0, 8 ), this );
Rectangle* rb = new Rectangle( QPointF( -40, -9 ), QPointF( 40, 9 ), this );
Rectangle* s1 = new Rectangle( QPointF( -25, -5 ), QPointF( 25, 5 ), this );
Rectangle* s2 = new Rectangle( QPointF( -25, -5 ), QPointF( 25, 5 ), this );
Disk* d = new Disk( 40.0, this );
Transformation* t1 = new Transformation( r1, QPointF( 0., 40.0 ) );
Transformation* t2 = new Transformation( r2, QPointF( 0., -40.0 ) );
Transformation* td = new Transformation( d, QPointF( 70., 0.0 ) );
Transformation*ts1 = new Transformation( s1, QPointF(-30.0,0.0), 0.0 );
Transformation*us1 = new Transformation( ts1, QPointF(0.0,0.0), 45.0 );
Transformation*ts2 = new Transformation( s2, QPointF(-30.0,0.0), 0.0 );
Transformation*us2 = new Transformation( ts2, QPointF(0.0,0.0), -45.0 );
Union* back = new Union( t1, t2 );
Union* head = new Union( rb, td );
Union* legs = new Union( us1, us2 );
Union* body = new Union( legs, back );
Union* all = new Union( head, body );
this->setGraphicalShape( all );
A disk is a simple graphical shape.
Definition objects.hpp:75

On pourra ensuite s'amuser à faire des déplacements un peu plus aléatoires pour le vaisseau. Vous devriez avoir maintenant une application qui ressemble à ça:

This is starship USS Enterprise NCC-1701 exploring new galaxies, but stalled in traffic jams...

6 - Des images, des figures

En fait, on peut étendre le principe précédent à des images quelconques. L'idée est la suivante:

  • Une image a une partie "pleine" (là où il y a des couleurs) et une partie "vide" (là où elle est transparente, c'est-à-dire là où son "alpha-channel" est à 0).
  • A partir du QPixmap de l'image, on fabrique un QBitmap qui est son masque plein/vide.
  • tirer un point aléatoire dans une telle image revient à tirer un point aléatoire dans l'image et à le retirer tant qu'on tombe dans un pixel vide du masque.
  • un point est dans l'image (isInside) lorsqu'il tombe dans un pixel plein du masque.

Pour charger une image, il suffit de déclarer un QPixmap et de lui donner un nom de fichier listé dans les ressources (cf collider.qrc). On utilisera le format GIF qui peut stocker de la transparence.

// dans collider.cpp, le pixmap ne doit être chargé qu'une fois.
QPixmap asteroid_pixmap(":/images/asteroid.gif");

On écrira donc ensuite une classe ImageShape qui hérite de GraphicalShape. On la construit en lui donnant un QPixmap et un MasterShape. On suppose qu'elle occupe les coordonnées (0,0) -> (w-1,h-1) où w et h sont la largeur et la hauteur de l'image.

ImageShape::ImageShape( const QPixmap & pixmap, const MasterShape* master_shape )
: _pixmap( pixmap ), _master_shape( master_shape )
{
_mask = _pixmap.mask(); // donnée membre QBitmap pour affichage
_mask_img = QImage( _mask.toImage().convertToFormat( QImage::Format_Mono ) );
}

Le QBitmap _mask servira lors des affichages de collision. C'est une structure optimisée pour l'affichage. Le QImage _mask_img est quant à lui optimisé pour les requêtes pixel par pixel et servira pour ImageShape::randomPoint et ImageShape::isInside.

Ecrivez ces méthodes selon le principe explicité ci-dessus. On utilisera la méthode QImage::pixelIndex qui retourne 0 sur du vide et 1 sur du plein.

Warning
Attention, _mask_img.pixel( p ) fonctionne si le QPoint p est à l'intérieur de l'image. Dans la méthode ImageShape::isInside, on vérifie donc d'abord si le point est dans la boite englobante de l'image ImageShape::boundingRect avant de tester si ce point touche un pixel du masque de l'image.

Pour l'affichage, on peut utiliser le code suivant:

void
ImageShape::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *)
{
painter->drawPixmap( QPointF( 0.0, 0.0 ), _pixmap );
if ( _master_shape->currentState() == MasterShape::Collision )
{
painter->setOpacity( 0.5 );
painter->setBackgroundMode( Qt::TransparentMode );
painter->setPen ( _master_shape->currentColor() );
painter->drawPixmap( QPointF( 0.0, 0.0 ), _mask );
}
}

Il ne reste plus qu'à faire une classe NiceAsteroid qui s'occupe de créer un joli astéroïde tournant sur lui-même. Il aura besoin de deux transformations t1 et t2. La première s'occupe de ramener l'image au centre (car celle-ci est entre les coordonnées (0,0) et (largeur,hauteur). La deuxième s'occupe juste de la rotation.

Dans NiceAsteroid::advance on augmentera régulièrement l'angle de la transformation t2, stockée dans la donnée membre _t ci-dessous:

void NiceAsteroid::advance(int step)
{
if (!step) return;
setPos( mapToParent( _speed, 0.0 ) );
_t->setAngle( _t->_angle + 2.0 );
MasterShape::advance( step );
}
Note
on pourra ajouter des accesseurs Transformation::setAngle et Transformation::angle pour lire/changer l'angle d'une transformation.

Vous devez avoir maintenant quelque chose qui ressemble à ci-dessous, en ayant remplacé les Asteroid par des NiceAsteroid.

Ca devient carrément encombré le coin !

7. Développements optionnels

Vous avez toute une base pour développer un petit jeu ou des petites animations. On peut citer les développements suivants:

  • combiner des images pour faire des vaisseaux jolis ou d'autres éléments.
  • avoir plusieurs images et animer certains éléments graphiques.
  • interactions avec la souris pour déplacer le vaisseau.
  • rajouter des tirs qui peuvent détruire les astéroïdes lors d'une collision.
  • ajout de forces/vitesse/accélération et de réactions à des collisions. On peut s'inspirer du TP3 d'INFO626.
  • optimiser les tests de collision en gérant les boîtes englobantes et en tenant compte de l'aire des formes.

N'hésitez pas à me poser des questions si vous ne voyez pas comment démarrer.

TP à rendre

  • Vous écrirez un petit README (texte) dans lequel vous préciserez les points suivants :
    • nom(s), prénom(s), groupe (monôme ou binôme)
    • Vous listerez d'abord les points du TP que vous avez abordés, en précisant si selon vous, vous avez traité complètement ou traité partiellement la question. Précisez aussi pour chaque question si votre code fonctionne ou dans quelle mesure il fonctionne.
    • vous placerez dans une archive (zip ou tar.gz) votre README ainsi que vos sources/entêtes/fichiers de config/images utiles, de façon à ce que je puisse tout recompiler en tapant "qmake" puis "make".
    • le nom de votre archive sera de la forme TP-XXX-YYY.*XXX et YYY désignent les noms respectifs des étudiants du binôme.
    • vous m'enverrez votre archive via TPLab , une première version à la fin du TP, une autre avant le mardi 17 octobre 2023 minuit.