Modéliser en C++, designer avec QML : le jeu de patience

Stéphane Fabry

Cet article est un petit tutoriel qui vise à montrer les différents moyens d'interfacer QML avec le C++. On ne rentre pas dans le code C++ qui est supposé connu par le lecteur, on explique les concepts QML et en particulier là où il y a une interaction avec le C++.

Commentez

- Titre : Modéliser en C++, designer avec QML : le jeu de patience

- Auteur : Stéphane Fabry

- Parution : 15 mai 2012

- Mise à jour : 29 juillet 2012

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2012 Stéphane Fabry. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- I-A. Un projet réel comme but

- I-B. Prérequis

- I-C. Code source

- II. Description des moyens de communication entre QML et C++

- II-A. Ajout de propriétés au contexte QML

- II-A-1. Affichage du tapis de jeu

- II-A-2. Modification du nom du jeu depuis le C++

- II-A-2-a. Exploration de main.cpp

- II-A-2-b. Contexte

- II-A-3. Un peu de JavaScript dans tout ça

- II-B. Ajout d'objets au contexte QML

- II-B-1. Modélisation C++ d'une carte

- II-B-2. Design QML d'une carte

- II-B-3. Fournir la carte au QML depuis le C++

- II-B-4. Amélioration du design de la face avant des cartes

- II-C. Utiliser les objets directement depuis le QML

- III. Modélisation statique

- III-A. Piles ou listes de cartes

- III-A-1. Première possibilité : un modèle QML

- III-A-2. Affichage d'une liste de cartes en QML

- III-A-3. Transmission d'une liste d'objets C++

- III-A-4. Utilisation de QAbstractItemModel

- III-B. Jeu du solitaire en C++

- III-C. Affichage du Solitaire en QML

- III-C-1. Mockup - disposition des éléments

- III-C-2. Réalisation

- III-C-3. Vue du deck et de la défausse

- III-C-4. Le solitaire et les as : la vue arbre en QML

- III-D. Mise à l'échelle

- III-E. Bonus : le jeu de Freecell et un menu

- IV. Interactions utilisateur

- V. Remerciements

I - Introduction

Après avoir lu quelques articles sur le QML, j'étais un peu entre deux eaux : c'est super cool, magique, beau, etc. mais c'est JavaScript, déclaratif, ce n'est plus du vrai C++ avec les classes, les objets. Afin d'en avoir le cœur net, j'ai donc décidé de m'y plonger et d'essayer de faire une application dont l'interface est en QML et dont le modèle, le cœur est en C++. Pour un simple petit jeu comme le solitaire (un jeu de cartes, une réussite classique) QML et JavaScript peuvent certainement suffire, j'ai néanmoins voulu appliquer le principe de séparation de l'interface et du modèle afin d'évaluer QML pour un projet de plus grande envergure. L'arrivée de Qt5 me conforte dans l'idée que c'est une bonne voie, également pour de plus grandes applications bureautiques.

I-A - Un projet réel comme but

Dans ce genre d'apprentissage, il faut un but pas trop compliqué, un objectif que l'on peut garder dans le viseur afin d'intégrer les concepts et de mieux visualiser leur application concrète. C'est également à ce moment qu'on se pose des questions, qu'on tombe sur des problèmes qui ne se poseraient pas forcément en restant dans un tutoriel purement démonstratif des fonctions C++ et QML (des choses du genre « fonctionCpp() » ou encore « foo() » parlent en général très peu).

Au long de cet article, on va décrire le cheminement vers un petit jeu de cartes, le solitaire. Les concepts resteront assez généraux afin de produire rapidement un autre type de réussite et puis pourquoi pas un jeu type « Dame de Pique ». Partant d'une conception orientée objet, on introduira le QML petit à petit.

I-B - Prérequis

Une installation de Qt 4.7 (ou supérieure) fonctionnelle, QtCreator (v 2.2 ou plus). Côté QML, pas besoin de connaissances spécifiques pour démarrer, quelques sauts dans la documentation devraient permettre à tout lecteur de suivre aisément le tutoriel. Si le côté Qt et C++ vous intéresse, une connaissance de QObject, des mécanismes de signaux et slots ainsi qu'une compréhension du pattern modèle-vue-délégué de Qt sont un plus. Les concepts de base ne seront pas réexpliqués, mais le lecteur qui souhaite en savoir plus peut s'appesantir sur ces quelques articles : https://qt.developpez.com/tutoriels/mvc/apostille-delegates-mvd/, https://louis-du-verdier.developpez.com/qt/fondations/.

I-C - Code source

Le code source du jeu de patience se trouve sur Gitorious. Un tag est créé avec le nom de chapitre correspondant lorsque c'est renseigné dans l'article, il est donc possible de télécharger et compiler le code en suivant les différentes étapes afin tripoter le code en cours de lecture. Pour ceux qui n'ont pas Git, voici une archive zip du code source aux différentes étapes : Patience.zip.

II - Description des moyens de communication entre QML et C++

II-A - Ajout de propriétés au contexte QML

Pour commencer, on va construire le tapis de jeu et afficher le nom de la réussite dessus.

Lancer QtCreator et créer un nouveau projet Qt Quick Application. Choisir un nom pour le projet (par exemple, patience), sélectionner une cible desktop correspondant au système d'exploitation. QtCreator va générer une série de fichiers nécessaires pour lancer une application QML en pleine fenêtre, on peut dès à présent lancer l'application et voir le Hello World apparaître. Voir cet article de Dourouc si vous avez des problèmes pour créer le projet : https://tcuvelier.developpez.com/tutoriels/qt/mobile-meego/qml/premiere-application/

II-A-1 - Affichage du tapis de jeu

Tout d'abord on crée un nouveau fichier QML, Board.qml (attention la majuscule est importante !). Un fichier QML commençant par une majuscule met en place un composant réutilisable, que l'on pourra réutiliser à l'envi dans d'autres fichiers QML. Placer ce fichier dans le même répertoire que le fichier main.qml, de manière à être accessible directement. Une fois le fichier ouvert, il est possible de l'éditer en mode texte ou via le QML designer. Les deux sont complètement synchronisés, les modifications effectuées via l'un sont automatiquement répercutées sur l'autre.

Le langage QML est assez intuitif : il s'agit d'un langage déclaratif dans lequel on décrit l'interface, les composants et leur comportement. Il se présente sous la forme d'un arbre d'objets décrits par leurs propriétés.

Ajouter un élément texte, le positionner au centre (via les ancres) et dans le bas du tapis de jeu (board), avec une marge de 50 pixels par exemple. L'élément texte se place sous l'élément board dans l'arbre, il sera donc affiché par-dessus ce dernier. Dans le QML designer il est possible de glisser les éléments d'un niveau à l'autre à l'aide de la souris (l'arbre des objets est affiché en haut à gauche de l'écran). On peut obtenir après quelques manipulations simples le résultat montré ci-dessous. Les ancres sont le mécanisme de placement des objets de QML, on décrit simplement comment on positionne un objet par rapport aux autres (par exemple, placer la défausse à droite de la pile de cartes avec une marge de 50 pixels).

Le tapis de jeu dans le QML designer.

Toutes les fonctions de base sont accessibles dans le designer : on peut choisir la couleur, la police de caractères, l'alignement, etc. C'est assez facile à utiliser et l'avantage est que l'on voit tout de suite l'effet des modifications. Ci-dessous l'équivalent dans l'éditeur texte du code QML. Quelques points qui méritent explication :

- l'attribut id est un attribut réservé, il attribue un nom à notre élément pour y faire référence par la suite ;

- on positionne l'élément texte vis-à-vis du rectangle parent. On décide de le placer en bas, avec une marge de 10 pixels. Il est important de remarquer que le texte est entièrement contraint, positionné via les ancres. Il est centré horizontalement et attaché par le bas au rectangle. On ne lui fixe pas de taille (via width et height) car elle est déterminée par le texte et la police utilisée ;

- la taille donnée au rectangle vert (600x400) est une taille par défaut. Comme on définit le tapis en tant que nouvel élément réutilisable, il est de bonne pratique d'attribuer une taille par défaut.

import QtQuick 1.1

Rectangle {
id: mainRectangle
width: 600
height: 400
color: "#246612"

Text {
id: gameText
color: "#f3eaea"
text: "Game Name"
font.pointSize: 50
opacity: 0.3
anchors.bottom: mainRectangle.bottom
anchors.bottomMargin: 10
anchors.horizontalCenter: mainRectangle.horizontalCenter
font.family: "Verdana"
}
}

Afin d'afficher le nouveau tapis dans l'application, éditer le fichier main.qml et y ajouter l'élément Board. Cela va simplement le positionner comme seul élément (on va en ajouter d'autres bientôt) de la fenêtre. Démarrer l'application : et voilà, ce n'est pas plus compliqué !

import QtQuick 1.1
Board {

}

II-A-2 - Modification du nom du jeu depuis le C++

Pour modifier le nom de la réussite, on va donner accès à la propriété text de l'élément texte du tapis à l'extérieur du composant. Pour ce faire, on définit un alias dans Board.qml de la manière suivante :

Rectangle {
id: mainRectangle
property alias gameName: gameText.text

...

On peut maintenant modifier cette propriété depuis main.qml :

Board {
gameName: "nouveau jeu"
}

II-A-2-a - Exploration de main.cpp

Avant de modifier le nom depuis le C++, on examine le code généré par QtCreator dans main.cpp afin de mieux cerner le fonctionnement d'une application QML.

int main(int argc, char *argv[])
{
QApplication app(argc, argv);
QmlApplicationViewer viewer;
viewer.setOrientation(QmlApplicationViewer::ScreenOrientationAuto);
viewer.setMainQmlFile(QLatin1String("qml/patience/main.qml"));
viewer.showExpanded();
return app.exec();
}

Un objet QmlApplicationViewer est créé, il s'agit de la fenêtre principale du programme, dans laquelle est effectué le rendu QML. Dans Qt4, QmlApplicationViewer est une QDeclarativeView, qui elle-même hérite de QGraphicsView : c'est donc un QWidget. L'orientation du viewer est ensuite spécifiée, ceci n'est utile que pour les applications mobiles qui peuvent être orientées horizontalement ou verticalement. On affiche le widget puis on exécute la boucle d'événements de manière classique. On remarque également la fonction setMainQmlFile qui permet de dire quel fichier QML sert au rendu, on ne doit spécifier que le fichier QML principal, les composants se trouvant dans le même répertoire seront automatiquement trouvés.

II-A-2-b - Contexte

En dessous du capot, le viewer possède un contexte dans lequel va s'exécuter le code QML, on peut le récupérer via viewer.rootContext(); on peut alors y ajouter d'autres variables ou propriétés. Le contexte est le lien entre le C++ et le code QML (voir Communication entre QML et C++/Qt). Par exemple pour transmettre le nom de la réussite à afficher sur le tapis de jeu, on peut ajouter ceci dans le main.cpp :

viewer.rootContext()->setContextProperty("m_gameName", "Solitaire");

Ceci ajoute une propriété m_gameName au contexte, dont la valeur est Solitaire. Cette propriété est dorénavant accessible depuis le fichier main.qml :

Board {
gameName: m_gameName
...
}

Ainsi on peut injecter des propriétés, des valeurs et même des objets (on le verra plus tard) depuis le C++ vers le QML, le désavantage étant que si la propriété change, il faut refaire un appel à setContextProperty(), ce qui n'est pas très pratique.

II-A-3 - Un peu de JavaScript dans tout ça

Avec le layout pour positionner le texte, on peut voir que le texte reste centré lorsqu'on redimensionne la fenêtre. Tout se passe très bien, excepté pour le texte, qui reste avec une grosse police quelle que soit la taille de la fenêtre. On peut remédier à cela en introduisant un peu de JavaScript. Dans l'élément texte, on place :

font.pixelSize: Math.min(200, parent.width / text.length * 1.5)

Plutôt que d'avoir une taille de pixels fixe pour le texte, on lui donne une taille variable en fonction de divers paramètres que sont la taille de la fenêtre, le nombre de caractères du texte à afficher, etc. Il faut jouer avec la formule pour en voir l'effet, redimensionner la fenêtre et comparer avec une taille fixe.

On peut donc affecter une expression JavaScript à une propriété QML. Cette manière de faire est descriptive et non impérative (au contraire du C++) dans le sens où on décrit comment doit être le texte en fonction d'autres données (largeur de fenêtre ou autre). Ce lien est fort, car si la largeur est modifiée, la taille du texte sera mise à jour automatiquement, c'est un binding.

Le texte se redimensionne automatiquement lorsque la largeur de la fenêtre change.

Les sources de cette partie sont disponibles : https://gitorious.org/patience/patience/trees/Chap_II-A-3.

II-B - Ajout d'objets au contexte QML

II-B-1 - Modélisation C++ d'une carte

On crée une nouvelle classe Card, qui hérite de QObject, modèle d'une carte à jouer. Pour le moment le modèle est très simple, on définit les propriétés de la carte via Q_PROPERTY :

- suite : cœur, carreau, trèfle ou pique ;

- valeur : de l'as au roi ;

- couleur : rouge ou noir ;

- retourné : si la carte est visible ou non.

class Card : public QObject
{
Q_OBJECT
Q_ENUMS(Color)
Q_ENUMS(Suit)
Q_ENUMS(Value)
Q_PROPERTY(Color color READ color NOTIFY colorChanged)
Q_PROPERTY(Suit suit READ suit NOTIFY suitChanged)
Q_PROPERTY(Value value READ value NOTIFY valueChanged)
Q_PROPERTY(bool flipped READ flipped() WRITE setFlipped NOTIFY flippedChanged)
public:
enum Color { Red, Black };
enum Suit { Hearts = 0, Diamonds, Clubs, Spades };
enum Value { Ace = 1, Two, Three, Four, Five, Six, Seven, Height, Nine, Ten, Jack, Queen, King };

...
};

Les énumérations sont déclarées avec Q_ENUMS, c'est important afin que Qt et QML les reconnaissent comme si elles provenaient de Qt. Les propriétés sont ensuite définies et sont toujours accompagnées d'une clause NOTIFY qui renseigne le signal émis lorsque la propriété est modifiée. C'est du ressort du modèle C++ d'émettre ce signal à bon escient. La clause de notification est nécessaire pour maintenir la partie QML et la partie C++ synchronisée, c'est par ce biais que la partie QML saura que le modèle a changé et qu'elle pourra se mettre à jour. Techniquement, ces macros assurent l'indexation dans le système de métaobjets, pour plus de précisions techniques à ce sujet, je conseille la lecture de De QObject auxmétaobjets, une plongée au cœur des fondations de Qt .

Les propriétés couleur, suite et valeur sont en lecture seule (pas de méthode WRITE), ce qui signifie qu'elles ne pourront pas changer au cours du temps. C'est logique car une carte ne se transforme pas en une autre. Par contre la propriété flipped peut être modifiée, lorsqu'on veut retourner la carte par exemple.

Le constructeur initialise la valeur et la suite de la carte :

Card::Card(Suit s, Value v, bool flipped, QObject *parent) :
m_suit(s),
m_value(v),
QObject(parent),
m_flipped(flipped)
{
m_color = (s == Hearts || s == Diamonds) ? Red : Black;
}

La carte est invisible par défaut et la couleur est déduite de la suite dans le constructeur.

Les variables privées, accesseurs aux propriétés, etc. ne sont pas montrés ici, le code est simple et peut être examiné en détail dans le code source. Deux slots sont créés pour retourner la carte, on prend soin de n'émettre le signal de notification que lorsque l'état de la carte est modifié.

void Card::flip() {
m_flipped = !m_flipped;
emit flippedChanged(m_flipped);
}

void Card::setFlipped(bool flip) {
if (m_flipped != flip) {
m_flipped = flip;
emit flippedChanged(flip);
}
}

II-B-2 - Design QML d'une carte

On ajoute Card.qml au projet, de nouveau avec une majuscule car c'est un composant. Ce composant va dessiner une carte à l'écran. On commence par la définition de quelques propriétés : flipped pour savoir si la carte est retournée ou non, ainsi que la suite et la valeur de la carte. On utilise Item comme élément racine car la carte va être composée de plusieurs sous-éléments. Un item est l'élément de base dont héritent tous les autres items graphiques.

Item {
id: card
width: 100
height: 145

property bool flipped: true
property int suit: 0
property int value: 1
}

On ajoute ensuite l'élément flipable comme premier composant de la carte, il s'agit d'un composant qui possède un avant et un arrière et qui permet de retourner l'objet, cela convient donc très bien pour une carte à jouer. On utilise anchors.fill : parent afin de spécifier que le flipable prend toute la place disponible dans le parent, c'est-à-dire dans la carte. Sur la face avant, on dessine un rectangle et dans un premier temps, on affiche en texte la valeur et la suite de la carte. La face arrière affiche une image de dos de carte, il suffit de placer le fichier image dans le dossier des fichiers QML.
Un élément QML a une notion de machine d'états : par défaut, il se trouve dans l'état défini par ses propriétés, dont le nom est la chaîne vide. Ici, on définit un état supplémentaire, que l'on nomme back : dans cet état, il faut tourner la carte de 180 °. Enfin, on indique que l'on se trouve dans l'état back quand la propriété flipped de la carte est vraie. Remarquez la beauté et la concision de l'écriture déclarative.
Chaque item peut également se voir attribuer des transformations géométriques (voir la documentation pour plus de détails sur les possibilités). Ici on définit la rotation de l'élément autour de l'axe y, rotation qui est utilisée pour tourner la carte lorsque l'on passe dans l'état retourné.
Enfin, chaque item peut définir des transitions, il s'agit ici d'animer le retournement afin que celui-ci s'étale sur 500 ms et soit donc visible par l'utilisateur.

Flipable {
id: flipable
anchors.fill: parent

front: Rectangle {
...
}

back: Image {
anchors.fill: parent
source: "back.svg"
}

states: State {
name: "back"
PropertyChanges { target: rotation; angle: 180 }
when: card.flipped
}

transform: Rotation {
id: rotation
origin.x: flipable.width/2
origin.y: flipable.height/2
axis.x: 0; axis.y: 1; axis.z: 0     // set axis.y to 1 to rotate around y-axis
angle: 0                            // the default angle
}

transitions: Transition {
NumberAnimation { target: rotation; property: "angle"; duration: 500 }
}
}

Pour tester, il reste une chose à ajouter : une MouseArea pour retourner la carte lorsqu'on clique dessus. On définit cette zone de clic sur toute la carte, et on change simplement la propriété flipped de la carte lorsqu'on clique dessus !

Item {
id: card

...

Flipable {

...

}

MouseArea {
anchors.fill: parent
onClicked: card.flipped = !card.flipped
}
}

Capture d'écran de la carte pendant qu'elle se retourne.

II-B-3 - Fournir la carte au QML depuis le C++

setContextProperty peut aussi être utilisée pour ajouter un QObject au contexte : dans ce cas, toutes les propriétés, tous les signaux et tous les slots (plus les méthodes marquées Q_INVOKABLE) sont accessibles depuis le contexte QML.
Il est maintenant assez simple de fournir les cartes depuis le C++ afin que le QML puisse les afficher. On ajoute tout simplement dans le main.cpp les cartes au contexte QML.

Q_DECL_EXPORT int main(int argc, char *argv[])
{
QScopedPointer<QApplication> app(createApplication(argc, argv));

Card* card1 = new Card(Card::Hearts, Card::Six, true);
Card* card2 = new Card(Card::Spades, Card::King, false);

...

viewer.rootContext()->setContextProperty("m_card1", card1);
viewer.rootContext()->setContextProperty("m_card2", card2);
viewer.showExpanded();

QTimer* timer = new QTimer();
timer->setInterval(1000);
QObject::connect(timer, SIGNAL(timeout()), card1, SLOT(flip()));
QObject::connect(timer, SIGNAL(timeout()), card2, SLOT(flip()));
timer->start();

return app->exec();
}

Pour la démo, on ajoute un petit timer qui va retourner les deux cartes toutes les secondes. Ceci démontre que les propriétés des QObject sont liées au QML, et que lorsqu'elles changent de valeur le système en est averti (grâce au NOTIFY).
Ci-dessous on voit que le code QML de main.qml qui affiche les deux cartes est très simple : on place une carte via Card et on lie les propriétés flipped, suit et value à celles des données de la carte du modèle C++ (m_card).
Le désavantage de cette méthode est qu'il faut modifier le code à deux endroits, dans le QML et dans le C++ pour afficher un nouvel élément. Toute instance d'un objet que l'on veut afficher doit être créée côté C++ au préalable, on verra au point C qu'il existe une autre méthode qui permet au QML d'instancier directement autant d'objets C++ qu'il le désire !

Card {
id: card1
anchors.top: parent.top
anchors.topMargin: 100
anchors.left: parent.left
anchors.leftMargin: 150
suit: m_card1.suit
value: m_card1.value
flipped: m_card1.flipped
}

Les deux cartes se retournent toutes les secondes, reflétant ce qui se passe côté C++.

Vous l'aurez peut-être remarqué, le programme actuel comporte un petit bogue : en effet si on clique sur une carte pour la retourner manuellement, elle s'arrête de tourner toutes les secondes. En fait dans le MouseArea, on casse le binding original en effectuant une affectation directe de la propriété flipped :

onClicked: card.flipped = !card.flipped

Dans l'application finale, les clics de souris n'agiront plus directement sur l'état des cartes, mais enverront les signaux au modèle C++, qui décidera ce qu'il est bon de faire : de cette manière, on ne casse plus les binding des propriétés. Lien vers les sources du chapitre.

II-B-4 - Amélioration du design de la face avant des cartes

Afin de rendre le jeu moins austère, je propose un dessin à la main des cartes. N'étant pas un grand designer ce sera simple. On dispose de quatre images PNG des quatre couleurs de cartes : cœur, carreau, trèfle et pique. Les images sont placées dans le même répertoire que les fichiers QML et devront être distribuées avec les fichiers QML dans l'application finale. On crée un fichier QML supplémentaire, CardFace, qui représente la face d'une carte. Il s'architecture comme suit.

Rectangle {
id: faceRoot
property int suit: 0
property int value: 10
width: 100
height: 145

color: "#ffffff"
radius: 6
border.width: 1
smooth: true
border.color: "#000000"

function imageSuitPath(suit) {
if (suit === 0) return "Hearts.png"
if (suit === 1) return "Diamonds.png"
if (suit === 2) return "Clubs.png"
if (suit === 3) return "Spades.png"
}

function valueToText(value) {
if (value === 1) return "As"
if (value < 11) return value + '';
if (value === 11) return 'V';
if (value === 12) return 'D';
if (value === 13) return 'R';
}

Component {
id: cardFaceCorner

...
}

On commence par un rectangle, auquel on donne les propriétés suite et valeur (qui seront accessibles depuis l'extérieur) ainsi qu'une largeur et une hauteur par défaut et quelques caractéristiques de dessin : arrondis, etc. Ensuite on définit deux fonctions JavaScript utilisées plusieurs fois par la suite, la première donne le nom du fichier image en fonction de la suite de la carte, la seconde le texte à afficher en fonction de la valeur de la carte. On déclare un Component, il s'agit d'un composant QML, comme si on l'avait défini dans un fichier externe, qu'on va pouvoir répéter plusieurs fois. Le but ici est de dessiner le coin supérieur gauche, et de le répéter pour le coin inférieur droit.

Component {
id: cardFaceCorner

Item {
Text {
id: cornerText
text: valueToText(value)
anchors.left: parent.left
anchors.top: parent.top
font.pixelSize: faceRoot.width / 7
...
}

Image {
id: cornerRightImage
width: Math.min(faceRoot.width, faceRoot.height) / 7
height: Math.min(faceRoot.width, faceRoot.height) / 7
anchors.left: cornerText.right
anchors.verticalCenter: cornerText.verticalCenter
source: imageSuitPath(suit)
...
}

Image {
id: cornerLowImage
...
}

}
}

Dans les grandes lignes on a : un texte et deux images, le dimensionnement des éléments se fait en fonction des dimensions de la carte (auxquelles on a accès grâce à la définition en ligne du composant). Il est intéressant de remarquer que le composant ne peut avoir qu'un seul item de premier niveau, tout comme dans un fichier QML. C'est pour cela que les trois éléments graphiques sont englobés dans un élément Item.

Loader {
sourceComponent: cardFaceCorner
anchors.top: faceRoot.top
anchors.left: faceRoot.left
}

Loader {
sourceComponent: cardFaceCorner
rotation: 180
anchors.bottom: faceRoot.bottom
anchors.right: faceRoot.right
}

La déclaration du composant n'affiche rien, il faut instancier les éléments, une fois pour le coin supérieur gauche et une fois pour le coin inférieur droit. Afin de voir l'état d'avancement et surtout de peaufiner les paramètres de rendu (une marge de quatre pixels par-ci, un facteur d'échelle par-là), on peut lancer un aperçu du fichier CardFace.qml avec le QML Viewer. Dans Qt Creator, il suffit de presser F5 lorsqu'on édite le fichier QML, sinon menu Outils - Externe - QtQuick - Aperçu. C'est très pratique, on peut redimensionner la fenêtre, éditer le code (penser à sauver) et faire F5 dans le QML Viewer et le fichier QML se recharge automatiquement. Lien vers les sources de ce Chapitre.

Édition du code QML et visualisation directe du résultat dans le QML Viewer.

II-C - Utiliser les objets directement depuis le QML

Il existe encore une possibilité de lier les objets C++ au code QML de manière très élégante grâce à QObject. On peut instancier toute sous-classe de QObject directement depuis le QML et accéder à toutes les propriétés, tous les signaux et les slots de cet objet. Cette magie est très simple à mettre en place, même pour des classes déjà existantes : dans le main.cpp on ajoute la ligne suivante :

qmlRegisterType<Solitaire>("Patience", 1, 0, "SolitaireModel");

Elle enregistre un objet C++ afin qu'il soit reconnu en QML. Patience est le nom de la bibliothèque, suivi des numéros de version et ensuite du nom de composant QML qui correspond au QObject. Le nom ne doit pas forcément être celui de la classe C++, il convient de choisir un nom qui a un sens dans un contexte QML.

Dans le fichier QML, on commence par importer la nouvelle bibliothèque Patience avec le numéro de version renseigné lors de l'appel à qmlRegisterType. Dès cet instant, les nouveaux composants enregistrés dans cette bibliothèque sont disponibles. On peut donc créer un élément SolitaireModel, QtCreator le reconnaît même : ses propriétés et signaux slots sont disponibles en autocomplétion ! Ce qui est élégant, c'est que ce n'est plus le code C++ qui injecte des données dans le code QML comme on l'a vu dans les deux précédentes sections. Ici, le code C++ met des outils à disposition du QML afin d'en étendre les capacités. Le code QML a tout le loisir de créer plusieurs solitaires s'il le désire sans que le développeur C++ soit mis à contribution.

import QtQuick 1.1
import Patience 1.0

Item {
...

SolitaireModel {
id: solitaireModel
}
}

Le code du solitaire implémenté ici est une simple démo. On a deux propriétés randomSuit et randomValue, et un slot randomize qui va changer de manière aléatoire la valeur de ces propriétés. Le code QML utilise ces propriétés pour afficher une carte qui, lorsqu'on clique dessus, change aléatoirement. Ci-dessous le code qui implémente cette petite démo.

class Solitaire : public QObject
{
Q_OBJECT
Q_PROPERTY(QString name READ name NOTIFY nameChanged)
Q_PROPERTY(int randomSuit READ randomSuit NOTIFY randomSuitChanged)
Q_PROPERTY(int randomValue READ randomValue NOTIFY randomValueChanged)

public:
explicit Solitaire(QObject *parent = 0);

QString name() const { return "Solitaire"; }
int randomSuit() const { return m_randomSuit; }
int randomValue() const { return m_randomValue; }

signals:
void nameChanged(QString name);
void randomSuitChanged(int arg);
void randomValueChanged(int arg);

public slots:
void randomize();

private:
int m_randomSuit;
int m_randomValue;
};

Solitaire::Solitaire(QObject *parent) :
QObject(parent),
m_randomSuit(0),
m_randomValue(1)
{
// Initialize seed for random numbers
qsrand(QDateTime::currentDateTime().toTime_t());

randomize();
}

void Solitaire::randomize()
{
m_randomSuit = qrand() % 4;
m_randomValue = qrand() % 13 + 1;

emit randomSuitChanged(m_randomSuit);
emit randomValueChanged(m_randomValue);
}

Item {
width: 600
height: 400

SolitaireModel {
id: solitaireModel
}

Board {
anchors.fill: parent
gameName: solitaireModel.name

Card {
id: card1
anchors.top: parent.top
anchors.topMargin: 100
anchors.left: parent.left
anchors.leftMargin: 150
suit: solitaireModel.randomSuit
value: solitaireModel.randomValue
flipped: false

onClicked: solitaireModel.randomize()
}
}
}

Dans le code QML ci-dessus, on utilise le signal onClicked de l'élément Card. Ce signal n'existe pas nativement, il est déclaré dans Card.qml et émis lors du clic sur la carte (via MouseArea).

Item {
id: card
signal clicked()

...

MouseArea {
anchors.fill: parent
onClicked: card.clicked()
}
}

III - Modélisation statique

À la fin de ce chapitre, on aura une application qui affiche la zone de jeu et distribue les cartes. Aucune interaction (déplacement de cartes) ne sera encore possible. Tout est modélisé en C++ mais l'affichage, la partie GUI, sera bien séparé et effectué en QML. Le QML se limitera à de l'affichage, des animations et des interactions avec le modèle C++ dans lequel se trouve toute la partie logique.

Remarque : cette manière de faire va compliquer la solution pour un simple solitaire, le but de l'article étant de montrer les possibilités du QML, de voir si cette technique serait applicable dans de plus gros projets où l'on ne pourrait se passer du C++ (intelligence artificielle codée en C++, interface d'un logiciel déjà existant…).

III-A - Piles ou listes de cartes

La modélisation d'une pile de cartes, d'un tas, pioche ou défausse nécessite de modéliser une liste de cartes. De manière plus générale, on va décrire les possibilités de QML pour gérer des modèles de données (listes, arbres, etc.).

III-A-1 - Première possibilité : un modèle QML

Il est possible de créer un modèle très simplement en QML de la manière suivante :

ListModel {
id: deckModel
ListElement {
suit: 1
value: 5
}
ListElement {
suit: 2
value: 10
}
ListElement {
suit: 3
value: 12
}
}

Ceci crée un modèle liste de trois éléments, chacun disposant d'une propriété suit et value, ce qui permet de caractériser la carte à afficher. Chaque élément doit avoir les mêmes propriétés. Ce modèle n'est cependant pas un modèle C++, on ne s'y attardera donc pas dans cet article. Néanmoins, il peut s'avérer utile pour générer de petits modèles de test lors du prototypage de l'interface QML.

III-A-2 - Affichage d'une liste de cartes en QML

Le modèle exposé au paragraphe précédent peut être affiché par le code QML suivant :

Row {
id: row1
anchors.left: parent.left
anchors.leftMargin: 0
anchors.top: parent.top
anchors.topMargin: 0
Repeater {
model: deckModel
delegate: Card { suit: model.suit; value: model.value; flipped: false }
}
}

Row est un élément QML qui n'affiche rien mais qui dispose simplement ses enfants en ligne, l'un à côté de l'autre (il existe l'équivalent Column). Chacun des éléments enfants de la ligne est créé par l'élément Repeater. Ce dernier prend en paramètres un modèle et un délégué pour l'affichage. Le modèle dans ce cas, est simplement l'identifiant du modèle de la section précédente. Le délégué est l'élément QML qui va être répété par le Repeater autant de fois que nécessaire (autant de fois que d'éléments dans le modèle) ; dans ce cas, il s'agit de l'élément Card auquel on passe les paramètres suit et value du modèle. Ci-dessous le résultat de ce code QML (à placer dans le main.qml par exemple).

Affichage d'une liste de trois cartes.

III-A-3 - Transmission d'une liste d'objets C++

La deuxième possibilité pour afficher une liste de cartes en QML est de transmettre une liste de QObject* de la manière suivante (main.cpp) :

QList<QObject*> cardList;
for (int i = 1; i < 14; i++) cardList.append(new Card(Card::Clubs, Card::Value(i), true));
viewer.rootContext()->setContextProperty("m_cardList", QVariant::fromValue(cardList));

Cela transmet la liste d'objets comme valeur de la propriété m_cardList, qui peut ensuite être utilisée comme identifiant du modèle QML (dans le Repeater de la section précédente). La limitation de cette méthode réside dans le fait que, comme le suggère l'écriture QVariant::fromValue(cardList), une copie de la liste est passée au QML. Cette liste est donc statique, si plus tard on ajoute ou retire d'autres cartes de la liste, le système n'en sera pas averti, à moins d'appeler à nouveau la fonction setContextProperty.

III-A-4 - Utilisation de QAbstractItemModel

La troisième solution, la plus puissante, requiert d'implémenter un QAbstractItemModel du framework modèle/vuew de Qt. Cette approche est puissante car elle utilise le modèle Qt déjà connu, cela permet d'utiliser une vue QML ou une vue classique très facilement. Cela requiert néanmoins un peu plus d'effort d'implémentation pour générer le modèle.

Voici le CardListModel très basique utilisé, il hérite de QAbstractListModel pour plus de facilité :

class CardListModel : public QAbstractListModel
{
Q_OBJECT
public:
enum CardRoles {
SuitRole = Qt::UserRole + 1,
ValueRole,
FlippedRole,
ColorRole
};
public:
explicit CardListModel(QObject *parent = 0);
void pushCard(Card* card);
void pushCards(QList<Card*> cards);
Card* popCard();
int rowCount(const QModelIndex &amp;parent = QModelIndex()) const;
QVariant data(const QModelIndex &amp;index, int role) const;
public slots:
void flipAll();
private:
QList<Card*> m_cards;
};

Le stockage des cartes se fait dans une simple liste de Card*, les fonctions push et pop permettent d'ajouter et retirer des cartes du modèle. Attention de bien suivre les appels aux fonctions du modèle pour tout changement dans celui-ci (comme beginInsertRows) car sans cela la vue QML (ou n'importe quelle autre vue) ne sera pas synchronisée. Consultez la documentation (QAbstractItemModel) pour plus de détails à ce sujet si vous n'êtes pas familier du framework modèle vue de Qt.

On définit plusieurs rôles aux données du modèle, chacun étant une propriété de carte qui sera visible depuis le code QML. La fonction data est surchargée de manière à répondre aux demandes de données concernant ces nouveaux rôles :

QVariant CardListModel::data(const QModelIndex &amp;index, int role) const {
if (index.row() < 0 || index.row() > m_cards.count())
return QVariant();
const Card *card = m_cards[index.row()];
if (role == SuitRole) return card->suit();
if (role == ValueRole) return card->value();
if (role == ColorRole) return card->color();
if (role == FlippedRole) return card->flipped();
return QVariant();
}

Afin que ces rôles nouvellement définis soient visibles depuis QML, il reste à les enregistrer, ce qui est fait dans le constructeur du modèle :

QHash<int, QByteArray> roles;
roles[SuitRole] = "suit";
roles[ValueRole] = "value";
roles[ColorRole] = "color";
roles[FlippedRole] = "flipped";
setRoleNames(roles);

Enfin le slot flipAll est là pour le show, il retourne chacune des cartes de la liste, et grâce au signal dataChanged, la vue QML sera toujours synchronisée.

void CardListModel::flipAll() {
foreach(Card* card, m_cards) card->flip();
emit dataChanged(index(0), index(rowCount() - 1));
}

III-B - Jeu du solitaire en C++

Dans cette section, on décrit la modélisation C++ du jeu de solitaire, et on donne les déclarations des objets qui seront utilisés dans le QML par la suite. L'implémentation n'est pas expliquée mais est commentée dans le code. Le jeu de solitaire comprend les éléments suivants :

- la donne : le reste des cartes après distribution ;

- la défausse : pile de cartes retournées depuis la donne ;

- le jeu : les sept piles de cartes du solitaire ;

- les as : les quatre piles de cartes sur lesquelles on monte les as et les autres cartes.

Tout d'abord, retour sur la classe Card qui modélise une carte à jouer (dont l'interface est rappelée ci-dessous). On y trouve les propriétés color, suit et value qui identifient la carte, la propriété flipped qui indique la face à afficher et la propriété selectable qui a été rajoutée. Cette dernière indique si le joueur peut interagir avec la carte (par exemple, dans le contexte du solitaire si la carte est déplaçable). Les propriétés sont en lecture seule, la partie QML ne pouvant pas choisir de retourner une carte ou de la rendre activable, c'est le modèle C++ qui va s'en charger. On définit donc ce qui est visible par QML : les propriétés, les signaux et les slots ainsi que les fonctions Q_INVOKABLE ; de même, ce qui est visible par le C++ : tous les autres membres publics.

class Card : public QObject
{
Q_OBJECT
Q_ENUMS(Color)
Q_ENUMS(Suit)
Q_ENUMS(Value)
Q_PROPERTY(Color color READ getColor NOTIFY colorChanged)
Q_PROPERTY(Suit suit READ getSuit NOTIFY suitChanged)
Q_PROPERTY(Value value READ getValue NOTIFY valueChanged)
Q_PROPERTY(bool flipped READ isFlipped NOTIFY flippedChanged)
Q_PROPERTY(bool selectable READ isSelectable NOTIFY selectableChanged)

public:
enum Color { Red, Black };
enum Suit { Hearts = 0, Diamonds, Clubs, Spades };
enum Value { Ace = 1, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King };

public:
explicit Card(QObject *parent = 0);
explicit Card(Suit s, Value v, bool isFlipped = false, QObject* parent = 0);

Color getColor() const { return color; }
Suit getSuit() const { return suit; }
Value getValue() const { return value; }
bool isFlipped() const { return flipped; }
bool isSelectable() const { return selectable; }
bool isValid() const { return value != 0; }

void flip();
void setFlipped(bool flip);
void setSelectable(bool select);

signals:
void colorChanged(Color);
void suitChanged(Suit);
void valueChanged(Value);
void flippedChanged(bool);
void selectableChanged(bool);

private:
Color color;
Suit suit;
Value value;
bool flipped;
bool selectable;
};

La donne et la défausse sont deux listes, deux tas de cartes. Ils sont chacun modélisés par un CardListModel qui hérite de QAbstractItemModel. Ce modèle stocke une liste de Card* et fournit plusieurs fonctions pour la manipulation : construction du paquet de 32 ou 52 cartes, mélange du tas, pioche d'une carte, etc. Ci-dessous, les grandes lignes du code.

class CardListModel : public CardModel
{
Q_OBJECT

public:
explicit CardListModel(QObject *parent = 0);
virtual ~CardListModel();

void pushCard(Card* card);
void pushCards(QList<Card*> cards);
Card* popCard();
QList<Card*> popLasts(int count);
QList<Card*> takeAll();
void clear();

void buildDeck32();
void buildDeck52();
void shuffle();

...

private:
QList<Card*> cards;
};

Pour la modélisation des sept piles de jeu et des quatre piles d'as, plutôt que d'employer une série de modèles de liste - ce qui est tout à fait envisageable -, on utilise un nouveau modèle avec une structure en arbre à deux niveaux de profondeur. Le premier contient un nœud par pile (soit sept nœuds), le second niveau contient les cartes. Ainsi, on peut mettre en évidence un problème côté QML. En effet, le QML est prévu pour afficher uniquement des modèles de listes à une seule colonne. Il n'est pas prévu de gérer les modèles en arbre directement dans les vues QML (ListView, Column, etc.). Dans le cas du CardListModel, les informations concernant les cartes sont passées via les rôles, enregistrés par la fonction setRoleNames. Dans le cas du modèle en arbre, on va devoir utiliser un élément QML en particulier afin d'extraire les sous-listes de cartes du modèle et de les afficher dans une ListView, par exemple. Cela est expliqué en détail dans la section Vue en arbre en QML. Le code de CardStackModel, la classe qui modélise les as et le jeu, n'est pas montré ici, son interface est similaire à CardListModel et elle implémente QabstractItemModel.

Voyons maintenant comme tout ça est mis en musique. La classe Solitaire modélise le jeu en lui-même, initialise le paquet, permet de lancer une nouvelle partie, mélange et distribue, puis autorise les mouvements de carte nécessaires. Toutes ces méthodes nécessaires au QML sont soit des slots, soit notées Q_INVOKABLE.

class Solitaire : public QObject
{
Q_OBJECT
Q_PROPERTY(QString name READ name NOTIFY nameChanged)

public:
explicit Solitaire(QObject *parent = 0);
QString name() const { return m_gameName; }

Q_INVOKABLE CardStacksModel* solitaireModel() const { return m_solitaire; }
Q_INVOKABLE CardStacksModel* acesModel() const { return m_aces; }
Q_INVOKABLE CardListModel* deckModel() const { return m_deck; }
Q_INVOKABLE QAbstractItemModel* discardModel() const;

signals:
void nameChanged(const QString&);

public slots:
void newGame();
void drawCards();
void solitaireMove(int startColumn, int endColumn);
void discardToSolitaire(int column);
void discardToAces(int column = -1);
void solitaireToAces(int solitaireColumn, int acesColumn = -1);

...

private:
QString m_gameName;
CardStacksModel* m_solitaire;
CardStacksModel* m_aces;
CardListModel* m_deck;
CardListModel* m_discard;
NLastProxyModel* m_discardFiltered;
};

Le solitaire est composé de deux listes, la donne et la défausse, et de deux arbres, le jeu de solitaire et les as. Ces modèles sont exposés via des méthodes invocables pour l'affichage via QML. Les slots permettent, quant à eux, la manipulation des cartes depuis le QML. À titre d'illustration, voici ci-dessous l'implémentation d'une nouvelle partie : on efface tout, on crée un jeu de 52 cartes, on le mélange et puis on distribue les cartes du solitaire.

void Solitaire::newGame() {
m_solitaire->clearAllStacks();
m_aces->clearAllStacks();
m_deck->clear();
m_discard->clear();

m_deck->buildDeck52();
m_deck->shuffle();

for (int i = 0; i < m_solitaire->stacksCount(); ++i) {
for (int j = i; j < m_solitaire->stacksCount(); j++) {
Card* card = m_deck->popCard();
if (card == 00) {
qWarning() << "Not enough cards in the deck to complete distribution";
return;
}
if (i == j) {
card->flip();
card->setSelectable(true);
}
m_solitaire->pushCard(j, card);
}
}
}

Le code complet se trouve dans les sources, on y trouve également le code de la réussite Freecell, qui s'implémente dans la classe Freecell (elle gère la logique de la partie de Freecell). Elle utilise également les modèles décrits précédemment de liste et d'arbre, ce qui permet d'écrire cette classe très rapidement.

Pour clore cette partie, un petit mot pour signaler l'existence de tests unitaires. Ces tests valident le modèle C++ qui est proposé. Ils sont totalement indépendants de l'interface graphique, que celle-ci soit écrite à base de QWidgets ou de QML. Ces tests font partie d'un projet séparé (un autre .pro car il y a un autre exécutable) qui se trouve également sur le dépôt.

III-C - Affichage du Solitaire en QML

III-C-1 - Mockup - disposition des éléments

Pour commencer, on réfléchit à la disposition des différents éléments sur l'écran les uns par rapport aux autres. On veut dessiner la donne et la défausse en haut à gauche, les as en haut à droite et le solitaire en bas de tout ça. Schématiquement, cela donne un dessin relativement flashy :

Disposition schématique.

Grâce aux ancres QML, il est très facile de produire ce positionnement : il suffit d'indiquer à chaque rectangle où il se trouve par rapport aux autres éléments. On peut en voir l'effet très rapidement dans QtCreator via QML Viewer ou le designer.

Rectangle {
property int globalMargin: 30

height: 600
width: 800

Rectangle {
id: deck
anchors.left: parent.left
anchors.top: parent.top
anchors.leftMargin: globalMargin
anchors.topMargin: globalMargin

...
}

Rectangle {
id: discard
anchors.left: deck.right
anchors.top: parent.top
anchors.topMargin: globalMargin
anchors.leftMargin: globalMargin

...
}

Rectangle {
id: aces
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: globalMargin
anchors.rightMargin: globalMargin

...
}

Rectangle {
id: solitaire
anchors.top: aces.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: globalMargin

...
}
}

III-C-2 - Réalisation

On applique maintenant les techniques décrites précédemment pour construire, bloc par bloc, le solitaire. Pour commencer, on met l'élément Board en fond et on crée l'élément SolitaireModel, ce qui va instancier la classe Solitaire côté C++. On récupère alors la propriété name pour la fournir à l'élément Board. Voici à quoi ressemblent Solitaire.qml et Main.qml.

main.qml

Item {
width: 800
height: 600

Solitaire {
anchors.fill: parent
}
}

Solitaire.qml

Item {
property int globalMargin: 30

SolitaireModel {
id: solitaireModel
}

Board {
anchors.fill: parent
gameName: solitaireModel.name

Rectangle {
id: deck
...
}

Rectangle {
id: discard
...
}

Rectangle {
id: aces
...
}

Rectangle {
id: solitaire
...
}
}
}

Petite remarque : main est le fichier QML principal, celui qui est exécuté par l'application. Dans ce cas, width et height donnent les dimensions par défaut de la fenêtre qui sera créée. Par contre pour le composant Solitaire, on ne donne ni dimensions ni ancrage (dans Solitaire.qml) car c'est un composant réutilisable, c'est à celui qui utilise Solitaire de le positionner et de le dimensionner, comme on le fait avec l'élément Rectangle. De fait on positionne et dimensionne le Solitaire depuis main.qml avec la ligne anchors.fill : parent, ce qui a pour effet que le solitaire prend toute la place disponible dans la fenêtre. On verra plus loin pour améliorer le design afin de pouvoir afficher aussi le Freecell.

III-C-3 - Vue du deck et de la défausse

Le deck et la défausse sont basés sur le modèle de liste, on va donc les afficher avec un nouveau composant QML : CardListView.qml. Il affiche les cartes côte à côte, et dispose des propriétés horizontalSpacing et verticalSpacing pour choisir d'afficher en ligne ou en colonne (ou en diagonale). Lorsqu'il n'y a pas de carte à afficher (modèle vide) on affiche un rectangle afin de représenter visuellement l'emplacement vide de la pile. Ce rectangle est totalement recouvert par la première carte de la liste. Ci-dessous le code simplifié de la vue QML. On utilise un simple Repeater pour gérer la création et la destruction des délégués (Card.qml). On stipule les propriétés x et y pour en contrôler la disposition. Note : le property alias donne simplement accès au modèle du répéteur depuis l'extérieur du composant.

Item {
id: root
property alias cardListModel : cardListView.model
property int horizontalSpacing: 25
property int verticalSpacing: 25

signal cardClicked(int index, int suit, int value)

width: cardsWidth + horizontalSpacing * Math.max(0, (cardListView.count - 1))
height: cardsHeight + verticalSpacing * Math.max(0, (cardListView.count - 1))

Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.leftMargin: 2
anchors.topMargin: 2
width: cardsWidth - 4
height: cardsHeight - 4
radius: 5
border.width: 3
color: "transparent"
border.color: "black"

MouseArea {
anchors.fill: parent
onClicked: root.cardClicked(-1, -1, -1);
onDoubleClicked: root.cardDoubleClicked(-1, -1, -1);
}
}

Repeater {
id: cardListView

delegate: Card {
id: thiscard
x: horizontalSpacing * index
y: verticalSpacing * index
value: model.value;
suit: model.suit;
flipped: model.flipped;
onClicked: root.cardClicked(index, suit, value)
}
}
}

À présent, on remplace dans le mockup les rectangles de la pioche et de la défausse par CardListView. Sans changer quoi que ce soit aux ancres, on renomme les Rectangle par des CardListView et on ajoute les propriétés qui vont bien. Afin de démontrer le bon fonctionnement, on remonte le signal clicked sur le deck et on appelle le slot drawCards du solitaire. Le code QML reste élégamment simple. Le code C++ appelé met à jour les modèles sous-jacents et les vues se tiendront automatiquement synchronisées.

CardListView {
id: deck
cardListModel: solitaireModel.deckModel()
horizontalSpacing: 0
verticalSpacing: 0

anchors.left: parent.left
anchors.top: parent.top
anchors.leftMargin: globalMargin
anchors.topMargin: globalMargin

MouseArea {
anchors.fill: parent
onClicked: { drawCards(); }
}
}

CardListView {
id: discard
cardListModel: solitaireModel.discardModel()
horizontalSpacing: 30
verticalSpacing: 0

anchors.left: deck.right
anchors.top: parent.top
anchors.topMargin: globalMargin
anchors.leftMargin: globalMargin
}

Afin que tout ceci fonctionne, il faut enregistrer les classes modèles dans le main.cpp comme ci-dessous. Sans cela, rien ne s'affiche.

qmlRegisterType<Solitaire>("Patience", 1, 0, "SolitaireModel");

qmlRegisterType<CardListModel>();
qmlRegisterType<QAbstractItemModel>();
qmlRegisterType<CardStacksModel>();

Ci-dessous, le résultat, exécuter l'application et cliquer sur la pioche pour voir les cartes défiler dans la défausse. Une fois la pioche vide, c'est la défausse qui est rechargée sur le tas. Remarque : on ne voit toujours que les trois dernières cartes dans la défausse, cela est voulu dans le jeu du solitaire (celui de mon grand-père en tous cas :p). En fait si on examine le code C++, on voit que le modèle de la défausse renvoyé est un proxy sur la défausse totale, proxy qui filtre les cartes pour n'afficher que les trois dernières. Lien vers les sources de la section.

CardListView en action.

III-C-4 - Le solitaire et les as : la vue arbre en QML

Les vues QML ne sont prévues que pour des modèles de liste : dès que le modèle devient plus compliqué, il faut ruser. C'est ici qu'intervient l'élément QML « VisualDataModel », assez compliqué ; on va essayer de le décrire au mieux. Tout d'abord, cet élément, comme son nom semble l'indiquer, est un modèle : il peut être utilisé partout où un modèle est demandé. Sa particularité est d'être visuel : en fait, il encapsule également le délégué (dans la terminologie Qt). C'est donc une espèce de modèle qui fournit des données mais aussi le délégué pour les visualiser.

Dans le cas présent, le VisualDataModel a une propriété très intéressante : on peut lui assigner un nœud du modèle via un QModelIndex, il se comporte alors comme la liste des enfants de ce nœud. De cette manière, il est possible d'itérer récursivement dans la profondeur d'un modèle en arbre de n'importe quel type (QAbstractItemModel). Afin d'afficher les colonnes du jeu, on crée un composant CardStackView, qui affiche les colonnes les unes à côté des autres ; pour afficher les colonnes, on réutilise le CardListView vu au paragraphe précédent.

Pour commencer, on modifie CardListView de manière à utiliser un VisualDataModel : pour la liste, cela ne change rien, car le nœud utilisé par défaut est la racine. Dans le code de CardListView, on donne accès aux propriétés model et rootIndex à l'extérieur du composant. On remplace le modèle qui était attribué directement par un composant VisualDataModel auquel on attribue le model et le rootIndex.

Item {
id: root
property alias cardListModel: cardListDataModel.model
property alias cardListModelIndex: cardListDataModel.rootIndex

...

Repeater {
id: cardListView
model: VisualDataModel {
id: cardListDataModel

delegate: Card {
...
}
}
}
}

Ensuite, du côté de CardStackView, on crée une ligne dans laquelle on répète une CardListView. On utilise aussi ici un VisualDataModel, simplement pour avoir accès à la fonction modelIndex(int) qui renvoie un QModelIndex que l'on attribue au rootIdex de CardListView. Le code est somme toute assez simple, expressif et bien réutilisé.

Item {
id: root
property QtObject cardStackModel

...

Row {
spacing: horizontalSpacing

Repeater {
id: stackRow
model: VisualDataModel {
id: visualModel
model: cardStackModel
delegate: CardListView {
horizontalSpacing: 0
verticalSpacing: root.verticalSpacing

cardListModelIndex: visualModel.modelIndex(index)
cardListModel: cardStackModel
}
}
}
}
}

Il reste bien sûr à remplacer les rectangles du mockup, par les CardStackView, ce qui se fait aisément comme pour les listes. Voici le résultat actuel, le jeu commence à prendre forme.

Placement des CardStackView pour le jeu de solitaire et pour les piles d'as.

III-D - Mise à l'échelle

Comme on peut le constater en redimensionnant l'application, tout ne se comporte pas au mieux. On ne voit pas toujours toutes les cartes, les cartes sont soit trop grandes soit trop petites...

Pour que tout se redimensionne au mieux, on va exprimer les contraintes suivantes en QML :

1. Toutes les cartes doivent avoir la même taille sur le plateau de jeu ;

2. La plus grande dimension est celle des sept colonnes de cartes en largeur, elle doit prendre toute la largeur disponible.

Pour le premier point, on a déjà bien préparé le travail : en effet, si on regarde les éléments CardListView, CardStackView et Card, on y a placé les propriétés cardWidth et cardHeight qui permettent de forcer la taille des cartes. Quelle taille leur donner ? C'est ici que le second point intervient : on veut maximiser la largeur des cartes de manière à occuper tout l'espace disponible. Il suffit de compter ce qu'on trouve sur la largeur du solitaire : deux marges, sept largeurs de cartes, six espacements entre cartes. D'où le code suivant dans Solitaire.qml :

Item {
id: root
property int globalMargin: Math.max(10, width / 50)
property int horizontalSpacing: width / 50
property int cardsWidth: (root.width - 2 * root.globalMargin - 6 * root.horizontalSpacing) / 7
property int cardsHeight: cardsWidth * 1.45

...

CardListView {
...
cardsWidth: root.cardsWidth
cardsHeight: root.cardsHeight
...
}
...
}

La propriété cardsWidth est calculée sur la base de la largeur de l'élément racine (Solitaire.qml) selon la formule ci-dessus, formule qui découle du comptage de ce qu'on trouve sur une largeur de solitaire. La hauteur de la carte est proportionnelle afin de garder toujours le même facteur de forme. Enfin, on affecte à chaque CardListView et CardStackView les dimensions calculées.

On peut redimensionner la fenêtre, les cartes se redimensionnent.

III-E - Bonus : le jeu de Freecell et un menu

Pour prouver la bonne conception et la bonne réutilisation des éléments QML, on implémente le design du Freecell. Ce dernier étant similaire au solitaire, on laisse au lecteur intéressé le soin de faire l'exercice.

Chose plus intéressante, réaliser un petit menu, un moyen de choisir au démarrage de l'application quelle réussite on veut jouer. Voici ce que l'on propose de réaliser : le solitaire et le Freecell sont affichés côte à côte ; lorsqu'on clique sur l'un, il passe en plein écran. La touche Escape permet de revenir au menu.

Le menu permet de choisir la patience à jouer.

Pour ce faire, on change quelque peu l'architecture dans le QML. Plutôt que d'avoir un Board (tapis vert) dans le solitaire et un dans le Freecell, on place un seul Board dans le main sur lequel on dispose les deux patiences. Ensuite on va utiliser la notion d'état en QML. On définit trois états : l'état par défaut est le menu en quelque sorte, tel que montré ci-dessus ; un second état est créé lorsque le solitaire est en plein écran ; et un troisième pour le Freecell. Dans le code ci-dessous, SelectionRectangle est un rectangle dessiné par-dessus le jeu afin d'en délimiter l'espace visuellement (cadre rouge ou bleu) dans le menu.

Item {
id: root
width: 900
height: 600

Board {
id: board
anchors.fill: parent
gameName: "Choose your game"

SelectionRectangle {
id: solitaireRectangle
gameName: solitaire.gameName

...

onClicked: root.state = "Solitaire"
Solitaire {
id: solitaire
anchors.fill: parent
}
}

SelectionRectangle {
id: freecellRectangle
gameName: freecell.gameName

...

onClicked: root.state = "Freecell"
Freecell {
id: freecell
anchors.fill: parent
}
}
}

states: [
State {
name: "Solitaire"
AnchorChanges {
target: solitaireRectangle
anchors.horizontalCenter: undefined
anchors.top: board.top
anchors.left: board.left
anchors.right: board.right
anchors.bottom: board.bottom
}
PropertyChanges {
target: solitaireRectangle
anchors.topMargin: 0
fullscreen: true
}
PropertyChanges {
target: board
gameName: solitaire.gameName
}
PropertyChanges {
target: freecellRectangle
opacity: 0
}
},
State {
name: "Freecell"
...
}
]

transitions: Transition {
AnchorAnimation { duration: 300; easing.type: Easing.InOutQuad }
NumberAnimation { target: solitaireRectangle; property: "opacity"; duration: 200; easing.type: Easing.InOutQuad }
NumberAnimation { target: freecellRectangle; property: "opacity"; duration: 200; easing.type: Easing.InOutQuad }
}

focus: true
Keys.onPressed: {
if (event.key == Qt.Key_Escape) root.state = ""
}
}

Qu'y a-t-il d'intéressant dans ce code? Tout d'abord, la déclaration des états Solitaire et Freecell. On y déclare tout ce qui change par rapport à l'état initial : un ancrage plein écran, plus de marges, le nom à afficher sur le plateau et on met l'autre réussite invisible. Le changement d'état se fait d'un clic sur la zone de prévisualisation simplement par cette déclaration onClicked: root.state = 'Solitaire'. Le retour au menu se fait via le clavier, on assigne alors à l'état une chaîne de caractères vide, ce qui indique un retour à l'état par défaut.

Enfin, pour animer le tout, on utilise des transitions. Les transitions définissent des animations lors d'un changement d'état. On anime ici le changement d'ancres et le changement d'opacité. Voir la documentation pour connaître tous les paramètres de ces animations (en boucle, en parallèle, différentes courbes, etc.).

Ceci termine le chapitre concernant le dessin du jeu. Seulement, il reste encore à faire vivre ce jeu, à gérer l'interaction avec l'utilisateur. Cela fait l'objet du chapitre 4, qui est donc plus orienté QML. L'interaction avec le C++ se limitant à appeler les slots du modèle de jeu. Lien vers le code final.

IV - Interactions utilisateur

Jusqu'ici rien ne bouge, pas moyen de déplacer les cartes et de faire avancer la réussite ! Cela sera abordé dans un autre article prochainement.

V - Remerciements

Un tout grand merci à Thibaut Cuvelier et à Louis du Verdier pour leur relecture attentive, tant sur le fond que sur la forme ainsi qu'à Claude Leloup pour sa double relecture orthographique.