Développement GameBoy #3 : Projet 1 - Tic Tac Toe

Après deux articles dans lesquels on a vu comment écrire un programme pour la GameBoy et comment utiliser son gamepad, il nous est déjà possible de développer des petits jeux. Alors certes on est limité à du texte pour le moment, car on n'a pas encore abordé le sujet des graphismes (promis c'est pour bientôt !), mais c'est pas ça qui va nous arrêter !

Histoire de ne pas partir dans quelque chose de trop compliqué pour ce premier projet, j'ai décidé de réaliser un Tic Tac Toe (plus connu sous le nom de « morpion » dans nos contrées). Pour ceux qui ne connaîtraient pas ce jeu, je vous invite à aller lire la page Wikipédia qui lui est consacrée.

Comme il s'agit là du premier projet concret qu'on réalise sur GameBoy, je vais pas mal entrer dans les détails histoire de ne perdre personne... L'article sera donc un peu long, et peut-être trop détaillé au goût de certains. Si des passages vous semble trop évidents, n'hésitez donc pas à les sauter ;).

Note

Cet article fait partie d'une série sur le développement GameBoy en C avec le compilateur SDCC et la bibliothèque gbdk-n. Cette série est toujours en cours et de nouveaux articles paraissent de temps à autre.

Articles de la série :

Quelques mots avant de commencer

Avant d'entrer dans le vif du sujet, je vais rapidement donner quelques informations sur l'affichage de la GameBoy en mode texte et vous présenter la bibliothèque <gb/console.h> étant donné qu'on va s'en servir dans notre programme.

L'affichage en mode texte

En mode texte, l'écran de la GameBoy peut être résumé à un terminal de 20×18 caractères. Le premier caractère en haut à gauche a pour coordonnées (0, 0), et le dernier caractère en bas à droite (19, 17) :

Découpage de l'écran en caractères

On peut donc afficher 360 caractères sur l'écran, ce qui devrait être suffisant pour notre petit jeu.

La bibliothèque <gb/console.h>

GBDK fournit quelques bibliothèques bien pratiques, parmi elles se trouve <gb/console.h> qui fournit quelques fonctions pour gérer l'affichage de la GameBoy en mode texte (qu'on utilisera en complément de la fonction printf() de la bibliothèque <stdio.h>).

Voici la liste des fonctions fournies :

  • void gotoxy(UINT8 x, UINT8 y) : permet de déplacer le curseur de la console aux coordonnées données,
  • UINT8 posx(void) : retourne la coordonnée x actuelle du curseur,
  • UINT8 posy(void) : retourne la coordonnée y actuelle du curseur,
  • void setchar(char c) : affiche le caractère donné à la position courante du curseur. Cette fonction ne modifie pas la position du curseur et contrairement à printf(), les caractères ne sont pas interprétés (\n et compagnie n'auront pas de comportements particuliers).

Je ne mets pas d'exemple de leur utilisation, on testera celles dont on a besoin directement dans le code du projet.

Développement du jeu

Étape 1 : Title Screen

Histoire de se remettre dans le bain, on va commencer par quelque chose de facile : afficher un écran de titre. Le déroulement du programme sera le suivant :

  1. Effacer tout ce qui se trouve à l'écran,
  2. Afficher notre écran de titre
  3. Attendre que le joueur appuie sur START et qu'il relâche le bouton

Pour des raisons de lisibilité du code, on placera tout ça dans une fonction dédiée plutôt que dans main(). Avec beaucoup d'originalité, je vais appeler cette fonction title_screen(). Voici l'état actuel de mon code (n'essayez pas de le compiler tel quel, cela ne fonctionnera pas encore) :

#include <stdio.h>
#include <gb/gb.h>
#include <gb/console.h>

void title_screen(void) {
    // 1. Effacer tout ce qui se trouve à l'écran
    clear_screen();
    // 2. Afficher notre écran de titre
    gotoxy(4, 5);
    printf("Tic Tac Toe");
    gotoxy(3, 15);
    printf("- Press START -");
    // 3. Attendre que le joueur appuie sur START et qu'il relâche le bouton
    waitpad(J_START);
    waitpadup();
}

void main(void) {
    // Boucle principale du programme qui alternera à terme entre affichage de
    // l'écran de titre et la phase de jeu.
    while (1) {
        title_screen();
    }
}

Dans la fonction main(), on peut remarquer que j'ai englobé notre appel à la fonction title_screen() dans une boucle infinie. Si je ne l'avais pas fait, le programme se serait terminé immédiatement après l'affichage de l'écran de titre. À ce stade on ne verrait pas trop la différence, mais lorsque l'on aura implémenté le jeu, on pourra boucler entre la phase de jeu et l'affichage de l'écran de titre.

Pour ce qui est de la fonction title_screen(), pas de surprise particulière, à un détail près : il n'existe pas de fonction pour effacer l'écran, il va donc falloir la coder nous même. Je vous colle ci-dessous la fonction que j'ai écrite :

void clear_screen(void) {
    UINT8 x;
    UINT8 y = 18;
    while (y) {
        y -= 1;
        x = 20;
        while (x) {
            x -= 1;
            gotoxy(x, y);
            setchar(' ');
        }
    }
}

Voilà, c'est tout pour l'écran de titre. Je vous mets ci-dessous une petite capture d'écran ainsi que des liens pour télécharger le code du projet et la ROM à l'état actuel :

Étape 01 : Écran de titre

Note

Étape 2 : Afficher le plateau

Maintenant qu'on a un « joli » écran de titre (on fait ce qu'on peut hein), on va s'atteler à afficher l'écran de jeu... enfin seulement les parties statiques (celles qui ne changeront pas durant la partie).

Il nous faut donc afficher une grille de 3×3 cases et quelques informations périphériques, comme le titre du jeu et une légende qui indique quel symbole est utilisé pour représenter le joueur (j'ai choisi arbitrairement que ce serait x) et quel symbole est utilisé pour l'ordinateur (O).

Ici rien de bien surprenant, c'est la même chose que pour l'écran de titre, sauf qu'on ne demande pas à l'utilisateur d'appuyer sur une touche... Le programme se résume donc à :

  1. On efface l'écran
  2. On « dessine » le plateau

Ce qui nous donne le code suivant :

void game_draw_board(void) {
    // 1. On efface l'écran
    clear_screen();
    // 2. On « dessine » le plateau
    gotoxy(4, 1);
    printf("Tic Tac Toe");
    gotoxy(1, 17);
    printf("X You - O Computer");
    gotoxy(0, 4);
    printf("       |   |   \n");
    printf("       |   |   \n");
    printf("       |   |   \n");
    printf("    ---+---+---\n");
    printf("       |   |   \n");
    printf("       |   |   \n");
    printf("       |   |   \n");
    printf("    ---+---+---\n");
    printf("       |   |   \n");
    printf("       |   |   \n");
    printf("       |   |   \n");
}

Note

Note: Je vais préfixer toutes les fonctions liées au jeu par game_, parce que je trouve ça mieux, mais vous n'êtes pas obligés de faire pareil 😉️.

Si on veut tester ce que ça donne, on peut modifier notre fonction main() de la façon suivante :

void main(void) {
    // Boucle principale du programme qui alternera à terme entre affichage de
    // l'écran de titre et la phase de jeu.
    while (1) {
        title_screen();
        game_draw_board();

        // On bloque temporairement le programme à cette étape histoire
        // d'avoir le temps d'admirer le résultat. On supprimera ça par
        // la suite.
        waitpad(J_START);
        waitpadup();
    }
}

Vous trouverez ci-dessous le code au stade de développement actuel du projet :

Étape 01 : Écran de jeu

Note

Étape 3 : État de la partie

On va maintenant ajouter un peu de code pour conserver l'état de la partie. On a pour le moment besoin d'enregistrer l'état des 9 cases (3×3 cases) qui composent la grille de jeu. On peut stocker ces informations dans un tableau de 9 cases que l'on va déclarer directement en haut du fichier (variable globale) :

#include <stdio.h>
#include <gb/gb.h>
#include <gb/console.h>

UINT8 GAME_BOARD[9];  // (3×3 cases)

// ...

Chacune des 9 cases de notre tableau peut se trouver dans l'un des 3 états suivants :

  • soit la case est vide,
  • soit elle contient le pion du joueur,
  • soit elle contient le pion de l'ordinateur.

On va donc déclarer quelques valeurs histoire de faciliter la lisibilité du code :

#define GAME_BOARD_CELL_EMPTY     ' '
#define GAME_BOARD_CELL_PLAYER    'x'
#define GAME_BOARD_CELL_COMPUTER  'o'

Maintenant il faut qu'on aborde une petite problématique. On a un tableau unidimensionnel pour stocker l'état de notre partie, mais la grille affichée au joueur est en 2 dimensions (et toute la logique de notre jeu va se baser sur cette vision bidimensionnelle de la grille) :

Array 1D vs Array 2D

Il va donc nous falloir effectuer un petit calcul pour convertir les coordonnées de la grille 2D vers une coordonnée 1D (un index) pour notre tableau. La formule pour passer des coordonnées 2D (x, y) à l'index (i) du tableau est la suivante:

i = y × LARGEUR_GRILLE + x

On peut donc écrire la fonction suivante afin de ne plus se préoccuper de ce détail par la suite :

UINT8 coord_2d_to_1d(UINT8 x, UINT8 y) {
    return y * 3 + x;
}

Étant donné que notre tableau n'est pas initialisé (et qu'il faut de toute façon le réinitialiser à chaque nouvelle partie), on va écrire une petite fonction qui s'occupe de ça :

void game_init(void) {
    UINT8 i;

    for (i = 0 ; i < 9 ; i += 1) {
        GAME_BOARD[i] = GAME_BOARD_CELL_EMPTY;
    }
}

Pour finir, on va créer une fonction game() qui gèrera tout le déroulement de la partie (initialisation, affichage de la grille, boucle de gameplay et affichage du résultat lorsque la partie est terminée). On remplira cette fonction au fur et à mesure de notre avancée dans le projet mais pour le moment elle ressemble à ça :

void game(void) {
    game_init();
    game_draw_board();

    // TODO Ajouter boucle de jeu ici
}

On en profite pour adapter le contenu de la fonction main() :

void main(void) {
    while (1) {
        title_screen();
        game();  // <- on appelle notre nouvelle fonction game()

        // On bloque temporairement le programme à cette étape histoire
        // d'avoir le temps d'admirer le résultat. On supprimera ça par
        // la suite.
        waitpad(J_START);
        waitpadup();
    }
}

Vous pouvez télécharger le code du projet mis à jour via le lien ci-dessous. Je ne mets pas de capture d'écran cette fois ci, car rien n'a changé visuellement par rapport à l'étape précédente.

Note

Étape 4 : Afficher l'état de la partie

À présent qu'on a affiché la grille de jeu et qu'on a de quoi enregistrer l'état de la partie, il nous faut écrire une fonction pour afficher cet état, c'est-à-dire afficher les pions des joueurs (les X et les O).

Aucune difficulté particulière dans cette fonction, je ne vais donc pas trop rentrer dans les détails : il faut juste faire correspondre les coordonnées logiques des cases (appelées x et y dans la fonction ci-dessous) à des coordonnées graphiques (appelées graph_x et graph_y) sur l'écran. Une fois ces coordonnées graphiques calculées il n'y a plus qu'à dessiner le bon symbole (GAME_BOARD_CELL_EMPTY, GAME_BOARD_CELL_PLAYER ou GAME_BOARD_CELL_COMPUTER) :

void game_draw_state(void) {
    UINT8 i;
    UINT8 x;
    UINT8 y;
    UINT8 graph_x;
    UINT8 graph_y;

    for (y = 0 ; y < 3 ; y += 1) {
        for (x = 0 ; x < 3 ; x += 1) {
            i = coord_2d_to_1d(x, y);
            graph_x = 4 + x * 4 + 1;
            graph_y = 4 + y * 4 + 1;
            gotoxy(graph_x, graph_y);
            setchar(GAME_BOARD[i]);
        }
    }

}

On peut à présent modifier un peu le code de la fonction game() si on veut tester notre code :

void game(void) {
    game_init();
    game_draw_board();

    // On rajoute des pions sur le plateau et on appelle notre fonction
    // game_draw_state() pour tester que tout fonctionne bien
    GAME_BOARD[0] = GAME_BOARD_CELL_COMPUTER;
    GAME_BOARD[4] = GAME_BOARD_CELL_PLAYER;
    game_draw_state();
}
Étape 04 : Affichage de l'étt de la partie

Note

Étape 5 : Afficher un curseur

Afin que le joueur puisse poser ses pions sur le plateau de jeu, il faut lui fournir un repère visuel pour lui permettre de sélectionner une case : on va donc hachurer la case active pour lui permettre de se repérer.

Il va d'abord falloir commencer par rajouter quelques variables globales pour mémoriser les coordonnées actuelles du curseur :

UINT8 GAME_CURSOR_X;
UINT8 GAME_CURSOR_Y;

On va ensuite modifier notre fonction d'initialisation pour qu'elle initialise également nos deux nouvelles variables :

void game_init(void) {
    UINT8 i;

    for (i = 0 ; i < 9 ; i += 1) {
        GAME_BOARD[i] = GAME_BOARD_CELL_EMPTY;
    }

    // On initialise le curseur au centre du plateau de jeu
    GAME_CURSOR_X = 1;
    GAME_CURSOR_Y = 1;
}

Enfin, on code une petite fonction permettant d'afficher le curseur :

void game_draw_cursor(UINT8 cursor_char) {
    // Position du centre de la case sur l'écran
    UINT8 graph_x = 4 + GAME_CURSOR_X * 4 + 1;
    UINT8 graph_y = 4 + GAME_CURSOR_Y * 4 + 1;
    // Position du caractère à dessiner dans la case du plateau
    UINT8 cx;
    UINT8 cy;
    for (cy = graph_y - 1 ; cy <= graph_y + 1 ; cy += 1) {
        for (cx = graph_x - 1 ; cx <= graph_x + 1 ; cx += 1) {
            // On saute le caractère central (car il contient potentiellement
            // un pion, on ne veut donc pas le masquer
            if (cx == graph_x && cy == graph_y) {
                continue;
            }
            // On dessine le caractère à la position indiquée
            gotoxy(cx, cy);
            setchar(cursor_char);
        }
    }
}

Sans rentrer dans les détails, cette fonction a une petite particularité : elle prend en paramètre le caractère qui sera utilisé pour hachurer la case. On aurait très bien pu hardcoder ce caractère, mais l'intérêt de le passer en paramètre est de pouvoir réutiliser la même fonction pour effacer le curseur (en dessinant le caractère espace à la place des hachures).

On peut à présent modifier la fonction game() de la manière suivante pour afficher le curseur et vérifier que tout fonctionne comme prévu :

void game(void) {
    game_init();
    game_draw_board();

    // On affiche le curseur pour vérifier que tout fonctionne
    game_draw_cursor('/');
}
Étape 05 : Affichage de l'état de la partie

Note

Étape 6 : Faire jouer le joueur

Après quelques étapes pas très passionnantes, on va enfin s'attaquer au cœur du programme : le jeu en lui-même. On va commencer par développer une boucle de gameplay pour permettre au joueur de sélectionner une case et y placer son pion. Le programme de cette boucle sera le suivant :

  1. Afficher le curseur
  2. Attendre que le joueur appuie sur l'une des touches HAUT, BAS, GAUCHE, DROITE, pour déplacer le curseur, ou sur la touche A pour placer son pion.
  3. Effacer le curseur
  4. Réagir en fonction du bouton appuyé par le joueur :
    • Si le joueur a appuyé sur une touche de direction, on déplace le curseur, en faisant attention à ne pas le déplacer en dehors des limites du plateau
    • Si le joueur a appuyé sur A, on met son pion dans la case, si cette dernière est vide
  5. Recommencer à l'étape 1 tant que le joueur n'a pas placé son pion.

Ce qui nous donne le code suivant :

void game_player_play(void) {
    UINT8 key;
    UINT8 i;

    // Boucle de jeu du joueur
    while (1) {
        // 1. On affiche le curseur
        game_draw_cursor('/');

        // 2. On attend que le joueur appuie sur l'une des touches HAUT, BAS,
        // GAUCHE, DROITE, pour déplacer le curseur, ou sur la touche A pour
        // placer son pion.
        key = waitpad(J_UP | J_DOWN | J_LEFT | J_RIGHT | J_A);

        // 3. On efface le curseur
        game_draw_cursor(' ');

        // 4. On réagit en fonction du bouton appuyé par le joueur :

        //    * Si le joueur a appuyé sur une touche de direction, on déplace le
        //      curseur, en faisant attention à ne pas le déplacer en dehors des
        //      limites du plateau
        if (key & J_UP && GAME_CURSOR_Y != 0) {
            GAME_CURSOR_Y -= 1;
        }
        if (key & J_DOWN && GAME_CURSOR_Y != 2) {
            GAME_CURSOR_Y += 1;
        }
        if (key & J_LEFT && GAME_CURSOR_X != 0) {
            GAME_CURSOR_X -= 1;
        }
        if (key & J_RIGHT && GAME_CURSOR_X != 2) {
            GAME_CURSOR_X += 1;
        }

        //    * Si le joueur a appuyé sur A, on met son pion dans la case,
        //      si cette dernière est vide
        if (key & J_A) {
            i = coord_2d_to_1d(GAME_CURSOR_X, GAME_CURSOR_Y);
            if (GAME_BOARD[i] == GAME_BOARD_CELL_EMPTY) {
                GAME_BOARD[i] = GAME_BOARD_CELL_PLAYER;

                // 5. On met fin à la boucle de gameplay une fois le pion du
                // joueur placé.
                break;
            }
        }

        // On attend que le joueur ait relâché le bouton pour passer à la
        // suite, sinon il risque d'avoir du mal à sélectionner la case
        // qu'il souhaite ;)
        waitpadup();
    }
}

Enfin, on modifie la fonction game() de la manière suivante pour permettre au joueur de jouer :

void game(void) {
    game_init();
    game_draw_board();

    // Boucle principale de la partie
    while (1) {
        game_player_play();
        game_draw_state();
    }
}
Étape 06 : Le joueur peut jouer

Note

Étape 7 : Faire jouer l'ordinateur

C'est bien beau de permettre au joueur de placer des pions, mais s'il n'a pas d'adversaire, il va vite s'ennuyer. On ne va pas développer tout de suite « l'IA » permettant à l'ordinateur de jouer correctement ; on va commencer par simplement lui faire placer son pion dans la première case vide disponible : ça nous permettra de finir de développer le jeu avant de se pencher sur cette problématique-ci.

Comme pour le joueur, on va créer une fonction qu'on appellera lorsque l'on voudra faire jouer l'ordinateur :

void game_computer_play(void) {
    UINT8 i;
    for (i = 0 ; i < 9 ; i += 1) {
        if (GAME_BOARD[i] == GAME_BOARD_CELL_EMPTY) {
            GAME_BOARD[i] = GAME_BOARD_CELL_COMPUTER;
            break;
        }
    }
}

Et on n'oublie pas de modifier la fonction game() pour alterner entre le tour du joueur et celui de l'ordinateur :

void game(void) {
    game_init();
    game_draw_board();

    // Boucle principale de la partie
    while (1) {
        game_player_play();
        game_draw_state();
        game_computer_play();
        game_draw_state();
    }
}
Étape 07 : On fait jouer l'ordinateur

Note

Étape 8 : Vérifier l'état du jeu

À présent que le joueur et l'ordinateur peuvent jouer, il faut être en mesure de déterminer la fin de la partie. Nous allons donc écrire une fonction qui aura pour but d'analyser le plateau et qui déterminera dans lequel des quatre cas suivants on se trouve :

  • personne n'a gagné, mais il reste des cases vides : la partie continue,
  • le joueur a gagné (il a aligné 3 pions),
  • le joueur a perdu (l'ordinateur a aligné 3 pions),
  • personne n'a gagné et il ne reste plus de case vide : la partie est terminée ex aequo.

On va ajouter dans notre code quatre constantes qui correspondront à chacun des états ci-dessus :

#define GAME_STATUS_PLAYING   0
#define GAME_STATUS_WON       1
#define GAME_STATUS_LOST      2
#define GAME_STATUS_EQUALITY  3

Puis on va créer une fonction qui retournera l'une de ces constantes en fonction de son analyse du tableau de jeu. Pour le moment on va lui faire retourner systématiquement GAME_STATUS_PLAYING le temps d'écrire un peu de logique dans game(), on reviendra la modifier plus tard pour lui faire retourner la bonne valeur :

UINT8 game_check_status(void) {
    return GAME_STATUS_PLAYING;
}

On modifie donc la fonction game(), pour qu'elle appelle game_check_status() et qu'elle termine la partie lorsque nécessaire :

void game(void) {
    UINT8 status;

    game_init();
    game_draw_board();

    // Boucle principale de la partie
    while (1) {
        // Tour du joueur
        game_player_play();
        game_draw_state();
        status = game_check_status();
        if (status != GAME_STATUS_PLAYING) {
            break;  // on stoppe la boucle lorsque la partie est terminée
        }

        // Tour de l'ordinateur
        game_computer_play();
        game_draw_state();
        status = game_check_status();
        if (status != GAME_STATUS_PLAYING) {
            break;  // on stoppe la boucle lorsque la partie est terminée
        }
    }
}

On revient maintenant à notre fonction game_check_status(). On va lui faire analyser le plateau de la manière suivante :

  1. on va parcourir les cases ligne par ligne,
  2. puis les cases colonne par colonne,
  3. puis les cases de la première diagonale
  4. et enfin celles de l'autre diagonale.

Lors de chacun de ces parcours, on va compter les pions du joueur, les pions de l'ordinateur : si on trouve trois pions appartenant au même joueur lors du parcours, ce joueur aura gagné. On en profitera également pour noter s'il nous reste des cases vides.

On va commencer par vérifier les lignes, ce qui nous donne le code suivant :

UINT8 game_check_status(void) {
    UINT8 x;
    UINT8 y;
    UINT8 i;
    UINT8 player_score;
    UINT8 computer_score;
    UINT8 empty_cell = 0;

    // On parcourt les lignes
    for (y = 0 ; y < 3 ; y += 1) {
        // On (ré)initialise les scores
        player_score = 0;
        computer_score = 0;
        // On parcourt chaque case de la ligne
        for (x = 0 ; x < 3 ; x += 1) {
            i = coord_2d_to_1d(x, y);
            // On regarde ce qui se trouve dans la case et on compte
            // les points
            switch (GAME_BOARD[i]) {
                case GAME_BOARD_CELL_EMPTY:
                    empty_cell = 1;
                    break;
                case GAME_BOARD_CELL_PLAYER:
                    player_score += 1;
                    break;
                case GAME_BOARD_CELL_COMPUTER:
                    computer_score += 1;
                    break;
            }
        }
        // Enfin, on regarde si quelqu'un a gagné
        if (player_score == 3) {
            return GAME_STATUS_WON;
        }
        if (computer_score == 3) {
            return GAME_STATUS_LOST;
        }
    }

    return GAME_STATUS_PLAYING;
}

Ensuite on fait la même chose pour les colonnes (on inverse juste les boucles sur x et y, le reste du code est identique :

// [...]

// On parcourt les colonnes
for (x = 0 ; x < 3 ; x += 1) {
    player_score = 0;
    computer_score = 0;
    for (y = 0 ; y < 3 ; y += 1) {
        i = coord_2d_to_1d(x, y);
        switch (GAME_BOARD[i]) {
            // On ne revérifie pas pour les cases vides étant donné
            // que l'on a déjà parcouru tout le tableau dans la boucle
            // précédente...
            case GAME_BOARD_CELL_PLAYER:
                player_score += 1;
                break;
            case GAME_BOARD_CELL_COMPUTER:
                computer_score += 1;
                break;
        }
    }
    if (player_score == 3) {
        return GAME_STATUS_WON;
    }
    if (computer_score == 3) {
        return GAME_STATUS_LOST;
    }
}

// [...]

Puis on continue sur notre lancée pour vérifier la première diagonale :

// [...]

// On parcourt la première diagonale (case en haut à gauche jusqu'à la case
// en bas à droite)
player_score = 0;
computer_score = 0;
for (x = 0 ; x < 3 ; x += 1) {
    y = x;
    i = coord_2d_to_1d(x, y);
    switch (GAME_BOARD[i]) {
        case GAME_BOARD_CELL_PLAYER:
            player_score += 1;
            break;
        case GAME_BOARD_CELL_COMPUTER:
            computer_score += 1;
            break;
    }
}
if (player_score == 3) {
    return GAME_STATUS_WON;
}
if (computer_score == 3) {
    return GAME_STATUS_LOST;
}

// [...]

Puis l'autre diagonale (il s'agit du même code, le seul changement se situe au niveau de la ligne où on calcule la coordonnée y :

// [...]

// On parcourt la seconde diagonale (case en bas à gauche jusqu'à la case
// en haut à droite)
player_score = 0;
computer_score = 0;
for (x = 0 ; x < 3 ; x += 1) {
    y = 2 - x;  // <==============
    i = coord_2d_to_1d(x, y);
    // [...]
}
if (player_score == 3) {
    return GAME_STATUS_WON;
}
if (computer_score == 3) {
    return GAME_STATUS_LOST;
}

// [...]

Enfin, dernière étape : lorsque personne n'a gagné, il faut regarder s'il reste des cases vides afin de déterminer si la partie continue ou est terminée :

// [...]

if (empty_cell) {
    // Il reste au moins une case vide, on continue la partie
    return GAME_STATUS_PLAYING;
} else {
    // Il n'y a plus de case vide, la partie est terminée
    return GAME_STATUS_EQUALITY;
}

Note

Note : le code présenté dans cette partie peut être grandement optimisé : je suis en effet allé au plus simple. Je vous invite à regarder la version finale du projet (lien en fin d'article) afin de voir comment tout ça peut être amélioré.

Étape 08 : État de la partie

Note

Étape 9 : Afficher des écrans de fin de partie

Il nous reste plus qu'un petit détail à régler pour que le jeu soit jouable de A à Z : afficher un écran de fin de partie indiquant qui a gagné (ou si les joueurs sont ex aequo).

Pour ces écrans, on va juste remplacer la ligne de titre (Tic Tac Toe) par le résultat (gagné, perdu ou ex aequo) et la ligne du bas par une invitation à appuyer sur START. De cette façon, on laisse le plateau affiché pour que le joueur puisse constater le résultat.

On va donc créer une petite fonction chargée de modifier l'affichage en fonction de l'état qu'on lui donne :

void game_draw_game_over(UINT8 status) {
    clear_line(1);
    switch (status) {
        case GAME_STATUS_WON:
            gotoxy(4, 1);
            printf("YOU WON! :)");
            break;
        case GAME_STATUS_LOST:
            gotoxy(4, 1);
            printf("YOU LOST! :(");
            break;
        case GAME_STATUS_EQUALITY:
            gotoxy(5, 1);
            printf("EQUALITY!");
            break;
    }
    clear_line(17);
    gotoxy(2, 17);
    printf("- Press START -");
    waitpad(J_START);
    waitpadup();
}

Rien de bien compliqué ici, on remarquera tout de même l'appel à la fonction clear_line() que j'ai créée pour éviter de copier / coller le même bout de code à deux endroits... Voici donc le contenu de cette fonction :

void clear_line(UINT8 y) {
    UINT8 x = 20;
    while (x) {
        x -= 1;
        gotoxy(x, y);
        setchar(' ');
    }
}

Il ne nous reste plus qu'à appeler notre fonction game_draw_game_over() à la fin de la fonction game() (en dehors de la boucle de jeu) :

void game(void) {
    UINT8 status;

    game_init();
    game_draw_board();

    // Boucle principale de la partie
    while (1) {
        // [...]
    }

    game_draw_game_over(status);
}

On n'oubliera pas de supprimer notre couple waitpad(J_START); waitpadup() dans la boucle principale de la fonction main() étant donné qu'ils ne sont plus utiles...

Le jeu est à présent parfaitement jouable... même si « l'IA » de l'ordinateur est complètement pourrie...

Étape 09 : Écrans de fin de partie

Note

Étape 10 : Améliorer « l'IA » de l'ordinateur

Étant donné que cet article est déjà bien assez long et qu'écrire une bonne IA pourrait faire l'objet d'un article entier, on va faire simple : on va reprendre le code de la fonction game_check_status() et l'adapter un peu. Je ne vais pas tout présenter en détail, mais je vous montre ce que ça donne lors de l'analyse ligne à ligne du plateau :

void game_computer_play(void) {
    UINT8 x;
    UINT8 y;
    UINT8 i;
    UINT8 player_score;
    UINT8 computer_score;
    UINT8 last_empty_cell;
    INT8 loose_cell = -1;

    // On parcourt les lignes
    for (y = 0 ; y < 3 ; y += 1) {
        player_score = 0;
        computer_score = 0;
        for (x = 0 ; x < 3 ; x += 1) {
            i = coord_2d_to_1d(x, y);
            switch (GAME_BOARD[i]) {
                case GAME_BOARD_CELL_EMPTY:
                    last_empty_cell = i;
                    break;
                case GAME_BOARD_CELL_PLAYER:
                    player_score += 1;
                    break;
                case GAME_BOARD_CELL_COMPUTER:
                    computer_score += 1;
                    break;
            }
        }
        // Si l'ordinateur a aligné 2 pions, on se place directement dans la
        // case vide pour remporter la partie
        if (computer_score == 2 && player_score == 0) {
            GAME_BOARD[last_empty_cell] = GAME_BOARD_CELL_COMPUTER;
            return;
        }
        // Si le joueur a aligné 2 pions, on enregistre la case qui pourrait
        // faire perdre l'ordinateur et on poursuit l'analyse
        if (player_score == 2 && computer_score == 0) {
            loose_cell = last_empty_cell;
        }
    }

    // [...] -> on fait la même chose pour les colonnes et les diagonales

    // Si on arrive jusqu'ici, c'est qu'il n'y a pas moyen immédiat de
    // gagner... On va donc vérifier s'il y a une possibilité pour le
    // joueur de gagner, et si c'est le cas, on va placer notre pion dans
    // la case qui lui permettrait de gagner.
    if (loose_cell != -1) {
        GAME_BOARD[loose_cell] = GAME_BOARD_CELL_COMPUTER;
        return;
    }

    // Sinon on place le pion dans la dernière case vide trouvée
    GAME_BOARD[last_empty_cell] = GAME_BOARD_CELL_COMPUTER;
}

Je vous mets évidemment le code complet en téléchargement ci-dessous :

Note

Projet terminé

On arrive enfin à la fin de notre projet, vous en trouverez une version finalisée et un peu améliorée sur Github à l'adresse suivante :

Quant à moi, je retourne bosser sur les prochains articles qui traiteront des graphismes. Je vais essayer de découper le sujet en articles relativement courts, histoire qu'ils ne soient pas trop longs à lire et de pouvoir les sortir rapidement et régulièrement.