Quixo, le morpion de l'intégration: Un jeu en python sur console
Cet article est le premier d'une série relatant mon arrivée et mon intégration chez Marmelab
Lorsque je suis arrivé chez Marmelab, un jeu de réflexion nommé Quixo m'attendait sur le bar de la cuisine. Après quelques parties avec mes nouveaux collègues, ma mission pour ma première semaine d'intégration était claire : développer un programme en Python permettant de jouer à ce Quixo sur un terminal et jusqu'à deux joueurs.
A l'issue de cette semaine, je rédige un article - cet article - pour partager mon expérience.
Des cubes qui bougent... et qui changent tout
Le Quixo allie un système de déplacement original et des règles simples. Le jeu dispose de 25 cubes. Sur chaque cube, on retrouve une croix ou un cercle. Les autres faces sont neutres.
Le Quixo se joue à 2 ou à 4 avec deux équipes de deux joueurs. Qu'importe le nombre de joueurs, les règles sont les mêmes. Chaque joueur ou équipe choisit de jouer avec les cercles ou les croix. Puis, à tour de rôle :
- Saisit un cube neutre en périphérie du plateau qui porte sa marque ou un cube neutre.
- À l'aide du cube choisi, il pousse une rangée de cube, de façon à combler l'espace laissé par le cube.
- Lorsque 5 marques sont alignées (en rangée ou en diagonale), la partie est remportée par le ou les joueurs jouant cette marque.
Grâce au système de déplacement des cubes, le plateau du Quixo change rapidement à chaque tour. Il est donc difficile de prévoir plusieurs coups en avance.
Vous l'aurez compris, à part le fait d'aligner des croix et des ronds, Quixo n'a rien à voir avec le jeu du Morpion.
L'environnement technique : une expérience de développement optimale
Pour ce projet, j'ai utilisé les outils de prédilection de Marmelab:
- Git & GitHub pour le versionnement du code ;
- Docker pour containeriser l'appli et la rendre exécutable sans installation supplémentaire (à part Docker) ;
- Make pour faciliter les différentes commandes de l'application ;
- Travis CI pour automatiser les tests.
Je ne connaissais pas le fonctionnement de tous ces outils, bien que familier avec certains. Mais ça n'a pas été un problème durant cette semaine. Il y avait toujours quelqu'un à portée de voix pour m'aider lorsque j’en avais besoin.
Zoom sur Github, un incontournable pour le partage de code open source
GitHub offre un système de révision de code indispensable lorsque l'on travaille en équipe. Voici la façon dont on l'utilise chez marmelab.
Une fois que le code est assez mature pour être révisé, on crée une Pull Request (PR). Elle est composée d'une description résumant son but ainsi que d'une liste des tâches accomplies et des éventuelles tâches restantes. Et grâce à Travis, les tests sont lancés automatiquement à la création de la PR. Si les tests sont exhaustifs, on est sûr de ne pas livrer de régressions en production. Une fois celle-ci validée par l'équipe, la PR est "fusionnée" (merged) et le code est poussé sur la branche principale.
Ce système permet d'avoir du code de qualité approuvé par tous. Cela permet aussi d'avoir l'avis de toute l'équipe et de faire émerger de nouvelles solutions.
J'ai créé une PR après chaque tâche réalisée. Cela m'a permis d'avoir un point de vue extérieur sur mon projet et de pouvoir améliorer mon code. J'ai aussi pu m'apercevoir de problèmes que je n'avais pas envisagés. Voici un exemple de retour qui m'a permis d'améliorer la qualité du code :
Gestion de projet Agile
Comme tous les projets chez Marmelab, ce projet est géré grâce aux méthodes agiles.
Lors de la première matinée, je rencontre la Product owner (PO) - sorte de chef de projet agile. Nous nous verrons tous les matins lors d'un "daily" d'une vingtaine de minutes où nous faisons une rapide mise au point de l'avancée du projet.
Zoom sur Trello, un backlog et des user stories pour un sprint bien géré
Trello est un outil de gestion de projet assez simple. Il y a un board composé de 3 colonnes :
- To do, les cartes restantes à accomplir.
- Doing, les cartes en cours de réalisation.
- Done, les cartes achevées.
On peut ajouter d'autre colonnes, par exemple une colonne pour les cartes dont la PR est en révision mais qui n'a pas encore été approuvée.
Ces cartes représentent une histoire ou une action. Par exemple :
En tant que joueur, je veux gagner la partie lorsque j'aligne 5 cercles.
Si c'est nécessaire, la carte peut être accompagnée :
- d'une description ;
- d'un périmètre défini ;
- d'une maquette représentant le résultat ;
- ou de tout ce qui peut préciser le travail attendu pour cette tâche.
Lors de la première réunion avec la PO, je découvre les cartes qu'elle avait préparées. Les cartes représentent toutes des évolutions simples. Cela permet de bien suivre l'évolution du projet et d'identifier rapidement tout retard. Grâce à la conception de ces cartes, j'ai eu un résultat présentable rapidement. J'ai donc pu avoir des retours sur le projet le plus tôt possible. Voici par exemple la première carte du projet :
Grâce à ces cartes, j'ai pu me représenter précisément les développements requis pour le projet.
L'estimation, un outil de prise de décision produit
L'étape suivante est d'estimer le temps requis pour chaque carte. Nous discutons pour préciser le résultat attendu et nous donnons une estimation de durée comprise entre 0,25 jour et 1 jour (le sprint dure 5 jours). La PO peut donc suivre le développement quotidiennement et s'apercevoir rapidement lorsqu'une tâche risque de poser problème. Il est alors possible de trouver une solution, par exemple :
- Fractionner la carte en cartes plus restreintes et plus rapides.
- Re-prioriser la carte pour permettre d'avancer sur d'autres points.
- Faire un compromis vers une solution technique plus simple.
Une fois toutes les cartes estimées et priorisées, il faut les réaliser !
Réalisation du projet
Le projet est désormais découpé en une vingtaine de cartes. Les premières cartes permettent de mettre le projet en place et de réaliser les fonctions basiques du jeu :
- Affichage du board.
- Déplacement d'un cube à l'opposé du plateau sans choix de la direction.
- Ajout d'un second joueur.
- Fin de la partie si 5 symboles sont alignés.
- etc.
Pour chaque carte, je suivais le même schéma de développement.
La boucle de jeu
La boucle de jeu a le rôle d'organisateur dans le projet. Elle va appeler des fonctions aux noms simples mais réalisant des actions complexes. Voici la boucle de jeu après le développement de quelques cartes :
def game():
board = create_board()
while True:
coords = get_player_selection(board, player_team)
board = move_tile_to_opposite(board, coords, player_team)
print_board(board)
Pour l’instant on peut seulement sélectionner un cube qui sera déplacé à l'opposé du plateau.
Pour le développement de nouvelles fonctionnalités, je pars de cette boucle pour lister les fonctions nécessaires. Je commence par les fonctions les plus génériques pour terminer par les fonctions les plus spécifiques. Une fois la liste effectuée, je commence par développer les fonctions les plus spécifiques.
Une fonctionnalité en détail
Par exemple, lorsque je dois implémenter cette nouvelle règle : elle impose aux joueurs de ne déplacer que des cubes qui leurs appartiennent et qui sont sur le bord du plateau. J'aurai besoin de :
- Récupérer les cubes qui sont jouables.
- Vérifier si un cube est sur le bord du plateau.
- Vérifier si un cube appartient à un joueur.
Je commence par réaliser les deux conditions de façon simple :
def is_player_tile(player, value):
return player == value
def is_outside_tile(x, y):
return x == 0 or y == 0 or x == N_ROWS - 1 or y == N_COLS - 1
Pour renvoyer une liste contenant tous les cubes déplaçables par le joueurs, je n'ai plus qu'à appliquer cette condition sur tous les cubes du plateau :
def get_movables_tiles(board, player):
movables = []
for x in range(len(board)):
for y in range(len(board[x])):
if is_outside_tile(x, y) and is_player_tile(player, board[x][y]):
movables.append((x, y))
return movables
Après avoir modifié la fonction de sélection pour qu'elle restreigne les choix de l'utilisateur aux cubes présents dans une liste, je peux mettre à jour la boucle de jeu :
def game():
board = create_board()
while True:
movables = get_movables_tiles(board, player_team)
coords = get_player_selection(board, player_team, movables)
board = move_tile_to_opposite(board, coords, player_team)
print_board(board)
La fonctionnalité est implémentée !
Les tests unitaires
Il faut maintenant passer à la rédaction des tests. Ils sont nécessaires pour :
- vérifier le bon fonctionnement de la fonctionnalité ;
- s'assurer qu'il n'y aura pas de régression dans le futur ;
- faire valider la PR.
Pour tester les fonctions je n'ai besoin que d'une situation de départ et une situation cible. Je réalise en premier les tests évidents - par exemple avec un plateau de jeu vierge.
En reprenant l'exemple précédent, voici un des tests de la fonction get_movables_tiles
:
def test_get_movables_tiles(self):
player = CROSSES
empty_board = [
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0]
]
expected_movables_coords = [
(0, 0), (0, 1), (0, 2), (0, 3), (0, 4),
(1, 0), (1, 4), (2, 0), (2, 4), (3, 0), (3, 4),
(4, 0), (4, 1), (4, 2), (4, 3), (4, 4)
]
self.assertEqual(get_movables_tiles(empty_board, player), expected_movables_coords)
Après avoir réalisé quelques tests, je peux jouer ! C'est également une bonne façon de tester le jeu. Lorsque je trouve un bug, j'écris un nouveau test pour tester cette situation.
La jouabilité en console : pas évident
En début de semaine, j'ai essayé d'améliorer le visuel et la jouabilité du jeu. Je voulais capturer l'appui des touches fléchées de l'utilisateur. Cela s'est révélé être plus compliqué que prévu ; j'ai tout de même trouvé des bibliothèques appropriées, mais elles ne fonctionnaient plus dans un container docker. Après quelques heures de blocage, nous avons décidé de garder une interface sommaire et de continuer à développer les fonctionnalités.
Le jeu ressemblait alors à ça :
C'est un simple tableau avec tabulations. Les cubes sont représentés par [ ]
et leur marque par un 'O' ou un 'X'. Le choix de l'utilisateur est fait grâce à l'invitation de commande.
Après avoir développé les fonctionnalités du jeu, la priorité est donc d'améliorer le visuel et l'interaction avec le joueur. Les deux objectifs sont :
- des cubes plus grands avec une plus grande marque ;
- le joueur peut sélectionner un cube en déplaçant un curseur sur le jeu.
Curses : une bibliothèque qui ne mérite pas son nom
Un collègue me parle alors d'un module qui va me débloquer : curses. C'est une implémentation pour python de la bibliothèque C du même nom. Elle permet de développer des applications plein écran en console. Je réalise un script simple pour tester ce dont j'aurai besoin pour le jeu :
- affichage d'un cube avec une marque ;
- écoute du clavier et en particulier des touches fléchées.
Une fois le script réalisé, je valide que curses corrige tous les problèmes que j'avais avec l'autre bibliothèque de gestion d'événements clavier. J'ajoute donc curses au programme.
Curses permet de spécifier des coordonnées lors de l'impression écran, je peux donc facilement afficher des cubes plus grands. Il suffit de les dessiner grâce à des caractères ASCII et de les transformer en tableau de 2 dimensions représentant les coordonnées de chaque caractère.
Par exemple :
cross_raw = '''\
____________
| __ __ |
| \ \/ / |
| \ / |
| / \ |
| /_/\_\ |
|____________|
'''
cross = [[char for char in row] for row in cross_raw.split('\n')]
Ensuite, pour l'afficher à des coordonnées précises il suffit de parcourir ce tableau :
def print_tile(stdscr, tile, y_start, x_start, attr):
for y in range(len(tile)):
for x in range(len(tile[y])):
stdscr.addstr(y + y_start, x + x_start, tile[y][x], attr)
Dans cette fonction :
stdscr
est l'écran virtuel fourni par curses ;tile
est le tableau à 2 dimensions contenant les caractères du cube ;y_start
&x_start
sont les coordonnées de l'angle supérieur gauche du cube à afficher ;attr
est un masque binaire permettant d'ajouter une couleur ou un style aux caractères.
La capture de caractère n'a posé aucun problème. Il existe une fonction de l'écran virtuel renvoyant le nom de la touche pressée :
key = stdscr.getkey()
Grâce à cela, j'ai pu améliorer l'aspect et la jouabilité du jeu. Voici la dernière version du jeu :
Le curseur de sélection est vert. Le joueur peut parcourir les cubes grâce aux flèches de direction et confirmer son choix avec la barre espace.
Conclusion : Une intégration efficace et originale
Ce premier projet aura été très enrichissant. J'ai pu apprendre la façon de travailler à Marmelab en développant un jeu. Cela permet d'avoir une progression visible facilement et de faire une partie en fin de semaine devant toute l'équipe. J'étais aussi très content de faire un jeu car les problématiques sont différentes de celles qu'on rencontre dans le développement web.
Durant cette semaine, j'ai pu bénéficier de l'aide précieuse et bienveillante de l'équipe. J'avais notamment un tuteur - un collègue expérimenté - qui révisait et validait toutes mes PR. Cela m'a permis de garder de la rigueur dans mon travail et d'avoir un regard extérieur sur mon code. Lorsque j'ai eu besoin d'aide, il y avait toujours quelqu'un pour m'aider à trouver une solution.
Cette semaine aura donc été très positive pour moi, je suis content d'avoir rejoint l'équipe de Marmelab et de continuer l'intégration la semaine prochaine.
Si vous voulez jeter un coup d'oeil au code ou l'améliorer, n'hésitez pas : quixo-python !