Projet de synthèse : le Pokédex
On veut une fiche de pokémon qui affiche son nom, ses types et ses six statistiques, qu'on peut faire défiler avec deux boutons, et dans laquelle on peut chercher un pokémon par son numéro ou par le début de son nom.
C'est le projet le plus complet de la séquence PySide6 : il réunit tout ce que les projets précédents ont installé séparément (un callback simple, un état qui persiste entre deux clics, une comparaison qui déclenche un comportement différent) et y ajoute une vraie table de données, au sens du programme.
Pré-requis
Ce projet fait suite à Additionner deux nombres, au convertisseur, au compteur et à Devine le nombre. Il suppose en plus le chapitre Types construits terminé (listes, dictionnaires) : chaque pokémon y est un dictionnaire, et le pokédex entier une liste de ces dictionnaires.
Ce projet se fait en binôme. Il est volontairement assez gros pour qu'une seule personne ait du mal à le porter seule dans le temps imparti : c'est voulu, pas un défaut.
Cahier des charges
L'application doit :
- afficher, au démarrage, la fiche du premier pokémon de la table : son numéro, son nom, son ou ses types (colorés), ses six statistiques (PV, attaque, défense, attaque spéciale, défense spéciale, vitesse) ;
- un bouton Suivant qui passe au pokémon suivant, et reboucle au premier après le dernier ;
- un bouton Précédent qui fait l'inverse, et reboucle au dernier avant le premier ;
- un champ de recherche et un bouton Chercher : si le texte tapé correspond au numéro exact d'un pokémon ou au début de son nom (sans tenir compte des majuscules), la fiche affichée devient la sienne ; sinon, rien ne change ;
- un champ de recherche laissé vide ne doit rien déclencher.
Travailler en binôme : qui fait quoi
Le projet se scinde en deux responsabilités, séparées par une frontière nette : l'affichage et la navigation d'un côté, la recherche de l'autre. C'est la même séparation entre logique et affichage que vous pratiquez depuis Découverte de PySide6 (QML pour l'un, Python pour l'autre), pas un découpage arbitraire.
| Rôle A | Rôle B | |
|---|---|---|
| Écrit | afficher, suivant, precedent |
trouver_index, rechercher |
| Transfert depuis | le compteur (état persistant, global) |
les exercices de recherche dans une séquence (boucle-for.md) |
| Particularité | touche l'interface à chaque appel | trouver_index se teste seul, sans lancer l'application |
Déroulé, à une seule machine, rôles nommés :
- Rôle A pilote, Rôle B copilote (relit chaque ligne à voix haute avant qu'elle soit validée, vérifie contre le cahier des charges) : écrire
afficher, puissuivantetprecedent. Testable immédiatement. - On échange. Rôle B pilote, Rôle A copilote : écrire
trouver_index, puisrechercher. - Intégration à deux : relancer l'application, tester ensemble tous les cas du cahier des charges, y compris ceux qui ne doivent rien changer (recherche vide, recherche sans résultat).
- Vérification individuelle (section tout en bas) : chacun, seul, sans son binôme.
Sans rotation, sans responsabilité individuelle clairement nommée et sans vérification finale, un binôme produit de la coordination coûteuse, pas de l'apprentissage : c'est le risque de tout travail de groupe, et c'est pour ça que les trois règles ci-dessus ne sont pas optionnelles.
Les données : le pokédex est une table
Le fichier pokedex.csv contient 151 lignes, une par pokémon :
numero,nom,type1,type2,pv,attaque,defense,attaque_spe,defense_spe,vitesse,generation
1,Bulbizarre,Plante,Poison,45,49,49,65,65,45,1
2,Herbizarre,Plante,Poison,60,62,63,80,80,60,1
3,Florizarre,Plante,Poison,80,82,83,100,100,80,1
4,Salamèche,Feu,,39,52,43,60,50,65,1
Remarque la ligne de Salamèche : sa colonne type2 est vide, parce qu'il n'a qu'un seul type. C'est un cas à gérer dans afficher.
Le module csv, avec sa fonction DictReader, n'a pas encore été vu en cours : il est donc fourni, dans le squelette ci-dessous. Ce qu'il faut savoir suffit en une phrase : csv.DictReader lit un fichier CSV et renvoie, pour chaque ligne, un dictionnaire dont les clés sont les noms de colonnes. Après la ligne fournie, pokedex est donc une liste de dictionnaires, et pokedex[0] vaut :
{"numero": "1", "nom": "Bulbizarre", "type1": "Plante", "type2": "Poison",
"pv": "45", "attaque": "49", "defense": "49", "attaque_spe": "65",
"defense_spe": "65", "vitesse": "45", "generation": "1"}
Toutes les valeurs sont des chaînes
csv.DictReader ne devine pas les types : pokedex[0]["pv"] vaut la chaîne "45", pas l'entier 45. Il faudra convertir avec int(...) avant de s'en servir comme nombre, exactement comme pour un champ de saisie dans les projets précédents.
L'interface : le QML est fourni intégralement
Contrairement à la partie 1, tu n'as rien à modifier dans ce fichier. Il faut cependant le lire : c'est lui qui fixe le nom de chaque composant, donc le nom que le Python devra utiliser pour le retrouver avec findChild.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ApplicationWindow {
id: fenetre
visible: true
width: 460
height: 660
title: "Pokédex"
// Composant réutilisable : une ligne de statistique avec barre animée
component StatLigne: RowLayout {
id: ligne
property string libelle: ""
property color couleur: "#78C850"
property int valeur: 0
property int maxi: 255
Layout.fillWidth: true
spacing: 8
Label {
text: ligne.libelle
color: "#333"; font.pixelSize: 13
Layout.preferredWidth: 80
}
Rectangle {
id: fond
Layout.fillWidth: true
height: 18; radius: 9; color: "#e3e3e3"
Rectangle {
height: parent.height; radius: 9; color: ligne.couleur
width: fond.width * ligne.valeur / ligne.maxi
Behavior on width { NumberAnimation { duration: 450; easing.type: Easing.OutCubic } }
}
}
Label {
text: ligne.valeur
color: "#333"; font.pixelSize: 13
Layout.preferredWidth: 34
horizontalAlignment: Text.AlignRight
}
}
// Fond en dégradé
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: "#2b5876" }
GradientStop { position: 1.0; color: "#4e4376" }
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 18
spacing: 12
// Recherche
RowLayout {
Layout.fillWidth: true
TextField {
objectName: "recherche"
Layout.fillWidth: true
placeholderText: "Numéro ou nom..."
}
Button { objectName: "btn_rechercher"; text: "Chercher" }
}
// Carte
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: 20
color: "#f7f7f7"
ColumnLayout {
anchors.fill: parent
anchors.margins: 18
spacing: 8
Label { objectName: "numero"; text: "N°000"; color: "#999"; font.pixelSize: 15; Layout.alignment: Qt.AlignHCenter }
Label { objectName: "nom"; text: "?"; color: "#222"; font.pixelSize: 30; font.bold: true; Layout.alignment: Qt.AlignHCenter }
Image {
objectName: "sprite"
source: ""
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: 180
Layout.preferredHeight: 180
fillMode: Image.PreserveAspectFit
smooth: false
}
// Badges de type
RowLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 8
Rectangle {
objectName: "badge1"; radius: 13; color: "#999"
implicitWidth: t1.implicitWidth + 26; implicitHeight: 28
Label { objectName: "badge1_txt"; id: t1; anchors.centerIn: parent; text: ""; color: "white"; font.bold: true; font.pixelSize: 13 }
}
Rectangle {
objectName: "badge2"; radius: 13; color: "#999"; visible: false
implicitWidth: t2.implicitWidth + 26; implicitHeight: 28
Label { objectName: "badge2_txt"; id: t2; anchors.centerIn: parent; text: ""; color: "white"; font.bold: true; font.pixelSize: 13 }
}
}
Item { Layout.preferredHeight: 6 }
// Statistiques (barres animées)
StatLigne { objectName: "stat_pv"; libelle: "PV"; couleur: "#78C850" }
StatLigne { objectName: "stat_atk"; libelle: "Attaque"; couleur: "#F08030" }
StatLigne { objectName: "stat_def"; libelle: "Défense"; couleur: "#F8D030" }
StatLigne { objectName: "stat_ats"; libelle: "Atq. Spé."; couleur: "#6890F0" }
StatLigne { objectName: "stat_dfs"; libelle: "Déf. Spé."; couleur: "#78C850" }
StatLigne { objectName: "stat_vit"; libelle: "Vitesse"; couleur: "#F85888" }
Item { Layout.fillHeight: true }
}
}
// Navigation
RowLayout {
Layout.fillWidth: true
spacing: 10
Button { objectName: "btn_prec"; text: "◀ Précédent"; Layout.fillWidth: true }
Button { objectName: "btn_suiv"; text: "Suivant ▶"; Layout.fillWidth: true }
}
}
}
Lire avant d'écrire : repérer les poignées
Réponds sans écrire de Python, juste en lisant le QML ci-dessus.
- Combien de composants portent un
objectName? Fais-en la liste. StatLignea une propriétévaleur. Surstat_pv, comment appelle-t-on, depuis Python, la mise à jour de cette propriété ? (repense à.setProperty(...)des projets précédents)badge2avisible: falsepar défaut. À ton avis, pourquoi ? Quel pokémon de l'extrait CSV plus haut aurait besoin quebadge2reste invisible ?
Réponse
- Dix-sept :
recherche,btn_rechercher,numero,nom,sprite,badge1,badge1_txt,badge2,badge2_txt,stat_pv,stat_atk,stat_def,stat_ats,stat_dfs,stat_vit,btn_prec,btn_suiv. Les sixStatLignecomptent chacune pour un seulobjectName, même si chacune contient plusieursLabeletRectangleinternes sans étiquette. stat_pv.setProperty("valeur", ...).- Un pokémon mono-type n'a rien à afficher dans le second badge. Salamèche, dans l'extrait CSV, a un
type2vide : c'est exactement ce cas.
Le squelette Python
Crée app.py, à côté de pokedex.qml et de pokedex.csv téléchargés plus haut. Tout ce qui n'est pas dans vos deux responsabilités est déjà écrit : chargement de la table, repérage des composants, couleurs des types.
import sys
import csv
from PySide6.QtGui import QGuiApplication
from PySide6.QtQml import QQmlApplicationEngine
app = QGuiApplication(sys.argv)
engine = QQmlApplicationEngine()
engine.load("pokedex.qml")
fenetre = engine.rootObjects()[0]
with open("pokedex.csv", encoding="utf-8") as f:
pokedex = list(csv.DictReader(f))
COULEUR_TYPE = {
"Normal": "#A8A878", "Combat": "#C03028", "Vol": "#A890F0", "Poison": "#A040A0",
"Sol": "#E0C068", "Roche": "#B8A038", "Insecte": "#A8B820", "Spectre": "#705898",
"Acier": "#B8B8D0", "Feu": "#F08030", "Eau": "#6890F0", "Plante": "#78C850",
"Électrik": "#F8D030", "Psy": "#F85888", "Glace": "#98D8D8", "Dragon": "#7038F8",
"Ténèbres": "#705848", "Fée": "#EE99AC",
}
SPRITE = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{}.png"
numero = fenetre.findChild(object, "numero")
nom = fenetre.findChild(object, "nom")
sprite = fenetre.findChild(object, "sprite")
badge1 = fenetre.findChild(object, "badge1")
badge1_txt = fenetre.findChild(object, "badge1_txt")
badge2 = fenetre.findChild(object, "badge2")
badge2_txt = fenetre.findChild(object, "badge2_txt")
recherche = fenetre.findChild(object, "recherche")
btn_prec = fenetre.findChild(object, "btn_prec")
btn_suiv = fenetre.findChild(object, "btn_suiv")
btn_rechercher = fenetre.findChild(object, "btn_rechercher")
barres = {
"pv": fenetre.findChild(object, "stat_pv"),
"atk": fenetre.findChild(object, "stat_atk"),
"def": fenetre.findChild(object, "stat_def"),
"ats": fenetre.findChild(object, "stat_ats"),
"dfs": fenetre.findChild(object, "stat_dfs"),
"vit": fenetre.findChild(object, "stat_vit"),
}
index = 0
# --- Rôle A : affichage et navigation ---------------------------------------
def afficher() -> None:
"""Affiche le pokémon d'indice `index` sur la fiche."""
pass # à compléter
def suivant() -> None:
"""Passe au pokémon suivant (callback du bouton Suivant)."""
pass # à compléter
def precedent() -> None:
"""Passe au pokémon précédent (callback du bouton Précédent)."""
pass # à compléter
# --- Rôle B : recherche ------------------------------------------------------
def trouver_index(requete: str) -> int | None:
"""Cherche un pokémon par numéro exact ou par début de nom (insensible à la casse).
Renvoie son indice dans `pokedex`, ou None si rien ne correspond.
>>> trouver_index("4")
3
>>> trouver_index("saLA")
3
>>> trouver_index("zzzzz")
"""
pass # à compléter
def rechercher() -> None:
"""Callback du bouton Chercher : lit le champ recherche, saute au pokémon trouvé."""
pass # à compléter
btn_suiv.clicked.connect(suivant)
btn_prec.clicked.connect(precedent)
btn_rechercher.clicked.connect(rechercher)
afficher()
if __name__ == "__main__":
import doctest
doctest.testmod()
sys.exit(app.exec())
if __name__ == \"__main__\": en bas du fichier
Ce bloc lance les doctests de trouver_index avant d'ouvrir la fenêtre. C'est pratique pour le rôle B (étape 3) : on peut vérifier trouver_index sans jamais lancer l'interface graphique.
Étape 1 (Rôle A pilote) - afficher
Écrire afficher
Complète afficher pour qu'elle mette à jour, à partir de p = pokedex[index] :
numeroetnom(texte simple, comme dans les projets précédents) ;sprite, avecSPRITE.format(p["numero"])commesource;badge1(couleur et texte) toujours ;badge2seulement sip["type2"]n'est pas vide (sinon,badge2.setProperty("visible", False)) ;- les six barres de
barres, en convertissant chaque statistique avecint(...).
Pour la couleur d'un type
COULEUR_TYPE.get(p["type1"], "#999999") : si le type n'est pas dans le dictionnaire, un gris par défaut plutôt qu'une erreur.
Solution
def afficher() -> None:
"""Affiche le pokémon d'indice `index` sur la fiche."""
p = pokedex[index]
numero.setProperty("text", "N°" + p["numero"].zfill(3))
nom.setProperty("text", p["nom"])
sprite.setProperty("source", SPRITE.format(p["numero"]))
badge1_txt.setProperty("text", p["type1"])
badge1.setProperty("color", COULEUR_TYPE.get(p["type1"], "#999999"))
if p["type2"]:
badge2_txt.setProperty("text", p["type2"])
badge2.setProperty("color", COULEUR_TYPE.get(p["type2"], "#999999"))
badge2.setProperty("visible", True)
else:
badge2.setProperty("visible", False)
barres["pv"].setProperty("valeur", int(p["pv"]))
barres["atk"].setProperty("valeur", int(p["attaque"]))
barres["def"].setProperty("valeur", int(p["defense"]))
barres["ats"].setProperty("valeur", int(p["attaque_spe"]))
barres["dfs"].setProperty("valeur", int(p["defense_spe"]))
barres["vit"].setProperty("valeur", int(p["vitesse"]))
zfill(3) complète avec des zéros à gauche jusqu'à 3 caractères : "1" devient "001". C'est une méthode de chaîne, pas une nouveauté conceptuelle.
Le squelette appelle déjà afficher() juste avant sys.exit(app.exec()) : lance, et vérifie que la fiche de Bulbizarre s'affiche complètement dès l'ouverture.
Étape 2 (Rôle A pilote) - suivant et precedent
Naviguer, avec bouclage
Transfert direct du compteur : une variable globale, modifiée puis suivie d'un rafraîchissement. La seule nouveauté est le bouclage : après le dernier pokémon, suivant doit revenir au premier.
L'opérateur qui boucle
(index + 1) % len(pokedex) : le reste de la division par la longueur de la liste revient toujours à 0 juste après avoir atteint la fin. Tu as déjà rencontré % pour tester la parité ; ici, il sert à boucler.
Solution
def suivant() -> None:
"""Passe au pokémon suivant (callback du bouton Suivant)."""
global index
index = (index + 1) % len(pokedex)
afficher()
def precedent() -> None:
"""Passe au pokémon précédent (callback du bouton Précédent)."""
global index
index = (index - 1) % len(pokedex)
afficher()
% sur un résultat négatif reste positif (contrairement à d'autres langages) : (0 - 1) % 151 vaut 150, pas -1. C'est ce qui fait boucler precedent vers le dernier pokémon sans code particulier.
Teste : lance l'application, clique sur Précédent dès le démarrage (encore sur Bulbizarre). Le pokédex doit boucler sur le dernier pokémon de la table, pas planter.
On échange les rôles
Rôle B prend le clavier. Rôle A copilote : relit chaque ligne avant qu'elle soit validée, vérifie contre le cahier des charges.
Étape 3 (Rôle B pilote) - trouver_index, testable seule
Une fonction pure, sans PySide6
trouver_index ne touche à aucun composant graphique : elle prend une chaîne, renvoie un indice ou None. C'est volontaire : tu peux l'écrire et la tester sans jamais lancer l'application, juste en exécutant le fichier (les doctests tournent avant app.exec()).
Complète trouver_index : parcours pokedex, et renvoie l'indice de la première ligne dont le numero est exactement égal à la requête, ou dont le nom, mis en minuscules, commence par la requête (déjà mise en minuscules).
Indice
Reprends le motif de plus_petite_lettre (boucle-for.md) : un parcours par indice avec enumerate, et un return dès qu'on trouve, plutôt qu'un accumulateur. Si la boucle se termine sans avoir renvoyé, il faut renvoyer None.
Solution
def trouver_index(requete: str) -> int | None:
"""Cherche un pokémon par numéro exact ou par début de nom (insensible à la casse).
Renvoie son indice dans `pokedex`, ou None si rien ne correspond.
>>> trouver_index("4")
3
>>> trouver_index("saLA")
3
>>> trouver_index("zzzzz")
"""
q = requete.strip().lower()
for i, p in enumerate(pokedex):
if q == p["numero"] or p["nom"].lower().startswith(q):
return i
return None
Lance le fichier sans cliquer sur rien et regarde la console : si les trois doctests passent, trouver_index est correcte, avant même d'avoir touché à l'interface.
Étape 4 (Rôle B pilote) - rechercher, le pont vers afficher
Le callback du bouton Chercher
rechercher doit : lire le champ recherche ; ne rien faire s'il est vide (relis le cahier des charges, point 5) ; sinon appeler trouver_index ; si un indice est trouvé, mettre à jour index (avec global) et appeler afficher.
La frontière avec le travail de Rôle A
rechercher appelle afficher, écrite par Rôle A. C'est le seul endroit du fichier où votre code se touche directement. Si afficher n'est pas encore terminée, teste trouver_index seule (étape 3) en attendant.
Solution
def rechercher() -> None:
"""Callback du bouton Chercher : lit le champ recherche, saute au pokémon trouvé."""
global index
q = recherche.property("text")
if q.strip() == "":
return
i = trouver_index(q)
if i is not None:
index = i
afficher()
rechercher fait et que trouver_index ne fait pas : décider quoi faire du résultat (rien si vide, rien si None, sinon changer index et rafraîchir). trouver_index se contente de répondre à la question qu'on lui pose ; c'est rechercher qui décide.
Intégration : tout brancher, et tester à deux
Les trois .clicked.connect(...) sont déjà dans le squelette fourni : il n'y a rien à ajouter ici, seulement à vérifier que les noms de fonctions correspondent à ce que vous avez écrit.
Test croisé, à deux, sur la machine
Reprenez le cahier des charges point par point, à voix haute, chacun votre tour :
- Bulbizarre s'affiche au démarrage, fiche complète.
Suivant× 151 revient sur Bulbizarre.Précédentdepuis Bulbizarre affiche le dernier pokémon (Mew, n°151).- Chercher
"25"affiche Pikachu (ou vérifie avec un numéro de ta table). - Chercher
"drac"(en majuscules ou minuscules, peu importe) affiche un pokémon dont le nom commence par ces lettres. - Chercher
"zzzzz": rien ne change. - Chercher, champ vide : rien ne change.
Un point qui échoue dit qui doit corriger, d'après le tableau de responsabilités du début de page.
Vérification individuelle
Seul, sans ton binôme, sur ton cahier :
- Explique en une phrase ce que fait
global indexdanssuivant, et pourquoitrouver_indexn'a besoin d'aucunglobal. recherchertesteif q.strip() == ""avant d'appelertrouver_index. Que se passerait-il si cette ligne n'existait pas, et qu'on cherchait avec un champ vide ? (repense à ce que renvoie"".startswith(""))- Si tu n'as écrit que
afficher/suivant/precedent(ou l'inverse), explique en une phrase ce que fait la fonction que tu n'as pas écrite.
La troisième question compte plus que les deux autres : un binôme qui ne comprend, chacun, que sa moitié du fichier n'a pas fini l'exercice, même si l'application fonctionne.
Ce qu'on retient
- Un projet assez complexe se découpe en responsabilités séparées mais reliées : ici, l'affichage/navigation d'un côté, la recherche de l'autre, reliées par
afficher()et la variableindex. - Une fonction qui ne touche à aucun composant graphique (
trouver_index) se teste seule, avec des doctests, sans lancer l'application. C'est souvent la partie la plus facile à mettre au point, précisément parce qu'elle est isolée. %ne sert pas qu'à tester la parité : il permet de boucler dans une séquence, y compris en arrière, sans code particulier pour le cas des bords.csv.DictReadertransforme un fichier de données en une liste de dictionnaires, exactement la structure déjà manipulée sur des données écrites à la main.
Pour aller plus loin
- Ajoute un bouton Favori (une étoile) qui bascule un booléen pour le pokémon courant. Où ranger cette information, sachant qu'elle doit survivre à la navigation ? (indice : une structure indexée comme
pokedex, mais séparée) - La recherche ne trouve que le premier pokémon qui correspond. Modifie
rechercherpour qu'un second clic, sur la même requête, propose le suivant qui correspond aussi. - Trie
pokedexpar une statistique (par exemple la vitesse) avant de l'utiliser. Que se passe-t-il pour la recherche par numéro ? Faut-il changertrouver_index?