Accueil > Algorithmes, Jeux, Tutos > Programmer un Tetris en C++ : Partie 1

Programmer un Tetris en C++ : Partie 1

Dans cet article, nous allons apprendre à programmer un clone d’un grand classique des jeux vidéos, j’ai nommé Tetris. En effet, ce jeu d’intelligence et d’adresse a été porté sur d’innombrables machines et en multitudes versions, alors, une version de plus ne lui fera pas de mal. Et cette version, c’est nous (vous) qui allons la programmer. Bon trêve de bavardages, profitez bien du tuto et n’oubliez pas de laisser un commentaire.

Pourquoi Tetris ?

Tetris est le meilleur choix pour débuter dans la programmation de jeux vidéo. Il comporte tout ce qu’un jeu requiert, la boucle principale, la gestion des évènements, la gestion des collisons, la gestion du score, on peut même aller plus loin avec la gestion du son. Il ne nécessite pas d’incroyables talents graphiques, toute personne sachant dessiner un bloc carré avec un logiciel de traitement d’images peut arriver à faire une version potable de Tetris. Et avec un peu d’efforts, notre version de Tetris peut rivaliser avec les versions commerciales actuelles.

Prérequis

Les bases du C++ (les classes, les méthodes, les références et les pointeurs) vous seront nécessaires pour suivre cet article. Pour le rendu graphique, c’est la SFML qui sera utilisée, mais il ne vous est pas nécessaire de la connaître au préalable, toutes les méthodes SFML utilisées seront d’abord expliquées. Bon commençons.

Avant de commencer, histoire de vous mettre l’eau à la bouche, voici ce à quoi devrait ressembler votre jeu une fois que vous serez venus à bout de tout le tutoriel.

Notre version de Tetris

Comme vous pouvez le voir, notre version de Tetris comporte tout ce qu’il faut pour nous faire perdre des heures devant notre ordinateur à déplacer des pièces de 4 blocs : les couleurs, la pièce fantôme (ghost piece), le score, la prévisualisation de la pièce suivante etc.

Bon, cette fois-ci, on commence pour de bon

La logique du jeu

Les pièces

L’une des difficultés de la programmation d’un Tetris est de savoir comment représenter les pièces du jeu et comment gérer leurs rotations. Le jeu en comporte 7, chacune composée de 4 blocs. Ces pièces sont appelées suivant leur forme O, I, S, Z, L, J, T. Comme une image est toujours plus parlante, voici les 7 pièces de Tetris et leurs rotations :

Le bloc sur lequel il y a un point sur la pièce représente son point de pivot.

Comment allons nous représenter ces pièces dans notre code source ? Comme le suggère l’image, le plus simple est de les représenter dans un tableau à 2 dimensions de longueur et de largeur 4. Nous mettrons la valeur 1 là où il y a un bloc normal, 2 pour un bloc pivot et 0 là où il n’y a aucun bloc.

Exemple avec la pièce O :

 int piece[4][4] = {
                      {0, 0, 0, 0},
                      {0, 1, 2, 0},
                      {0, 1, 1, 0},
                      {0, 0, 0, 0}
                   };

Pour les rotations, au lieu d’écrire du code inutilement compliqué pour tourner la pièce (ici déplacer les 0, 1 et 2 dans notre tableau), nous allons également stocker toutes les rotations de chaque pièce. Comment ? Avec un tableau à 4 dimensions. Nous avons 7 pièces, pour chaque pièce nous voulons stocker 4 rotations, et chaque rotation constitue un matrice de 4 x 4; tout ceci nous donne une déclaration de tableau comme ceci :

int pieces[7][4][4][4];

Ce qui nous amène à créer notre premier fichier que j’ai appelé shapes.h (shape veut dire forme en anglais). Il ne contiendra que les formes de nos pièces et leurs rotations comme expliqué précédemment.

/* shapes.h */
#ifndef SHAPES_H
#define SHAPES_H

const int NB_KINDS = 7; // Le nombre de types de pièces qu'on a
const int NB_ROTATIONS = 4; // Le nombre de rotations de chaque pièce
const int SIZE = 4; // La taille de la matrice de chaque rotation

static int PIECES[NB_KINDS][NB_ROTATIONS][SIZE][SIZE] =
{
	{ // O
		{
			{0, 0, 0, 0},
			{0, 1, 2, 0},
			{0, 1, 1, 0},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 0, 0},
			{0, 1, 2, 0},
			{0, 1, 1, 0},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 0, 0},
			{0, 1, 2, 0},
			{0, 1, 1, 0},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 0, 0},
			{0, 1, 2, 0},
			{0, 1, 1, 0},
			{0, 0, 0, 0}
		}
	},

	{ // I
		{
			{0, 0, 0, 0},
			{1, 1, 2, 1},
			{0, 0, 0, 0},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 1, 0},
			{0, 0, 2, 0},
			{0, 0, 1, 0},
			{0, 0, 1, 0}
		},

		{
			{0, 0, 0, 0},
			{1, 1, 2, 1},
			{0, 0, 0, 0},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 1, 0},
			{0, 0, 2, 0},
			{0, 0, 1, 0},
			{0, 0, 1, 0}
		}
	},

	{ // S
		{
			{0, 0, 0, 0},
			{0, 0, 2, 1},
			{0, 1, 1, 0},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 1, 0},
			{0, 0, 2, 1},
			{0, 0, 0, 1},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 0, 0},
			{0, 0, 2, 1},
			{0, 1, 1, 0},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 1, 0},
			{0, 0, 2, 1},
			{0, 0, 0, 1},
			{0, 0, 0, 0}
		}
	},

	{ // Z
		{
			{0, 0, 0, 0},
			{0, 1, 2, 0},
			{0, 0, 1, 1},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 0, 1},
			{0, 0, 2, 1},
			{0, 0, 1, 0},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 0, 0},
			{0, 1, 2, 0},
			{0, 0, 1, 1},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 0, 1},
			{0, 0, 2, 1},
			{0, 0, 1, 0},
			{0, 0, 0, 0}
		}
	},

	{ // L
		{
			{0, 0, 0, 0},
			{0, 1, 2, 1},
			{0, 1, 0, 0},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 1, 0},
			{0, 0, 2, 0},
			{0, 0, 1, 1},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 0, 1},
			{0, 1, 2, 1},
			{0, 0, 0, 0},
			{0, 0, 0, 0}
		},

		{
			{0, 1, 1, 0},
			{0, 0, 2, 0},
			{0, 0, 1, 0},
			{0, 0, 0, 0}
		}
	},

	{ // J
		{
			{0, 0, 0, 0},
			{0, 1, 2, 1},
			{0, 0, 0, 1},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 1, 1},
			{0, 0, 2, 0},
			{0, 0, 1, 0},
			{0, 0, 0, 0}
		},

		{
			{0, 1, 0, 0},
			{0, 1, 2, 1},
			{0, 0, 0, 0},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 1, 0},
			{0, 0, 2, 0},
			{0, 1, 1, 0},
			{0, 0, 0, 0}
		}
	},

	{ // T
		{
			{0, 0, 0, 0},
			{0, 1, 2, 1},
			{0, 0, 1, 0},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 1, 0},
			{0, 0, 2, 1},
			{0, 0, 1, 0},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 1, 0},
			{0, 1, 2, 1},
			{0, 0, 0, 0},
			{0, 0, 0, 0}
		},

		{
			{0, 0, 1, 0},
			{0, 1, 2, 0},
			{0, 0, 1, 0},
			{0, 0, 0, 0}
		}
	}
};

#endif

Maintenant qu’on a un moyen efficace de stocker nos pièces, voyons voir comment organiser notre projet. Il sera composé de 3 classes principales :

  • Piece : Abstraction de la pièce, cette classe nous permettra de manipuler sans nous soucier de leur représentation chaque pièce du jeu.
  • Board: Cette classe représente l’aire du jeu, c’est elle qui définira les méthodes de manipulation des pièces (déplacements, rotations) et la logique principale du jeu (le jeu est-il fini? , y – a – t’il des lignes à supprimer ? etc.), la gestion des collisions, … . Inutile de vous dire que c’est la plus importante.
  • Game: C’est dans cette dernière classe que nous définirons les méthodes de rendu à l’écran de ce qui se passe, les méthodes de pause et de reprise du jeu, et de calcul du score.

On commence tout de suite par la classe Piece

Piece

Cette classe n’a absolument rien de sorcier, elle définit seulement un objet de type Piece et ses attributs (son type, son orientation courante, sa couleur, sa position dans l’aire de jeu).

Voici sa définition

/* piece.h */
#ifndef PIECE_H
#define PIECE_H

#include "shapes.h"

enum { CYAN = 1, BLUE, ORANGE, YELLOW, GREEN, PURPLE, RED, GHOST }; // Les couleurs de chaque pièce, GHOST est pour la pièce fantôme

/* Les coordonnées du point de pivot de la pièce */
const int PIVOT_X = 1;
const int PIVOT_Y = 2;

class Piece
{

private:
	int kind; // Le type de la pièce
	int orientation; // Son orientation (sa rotation courante)
	int color; // Sa couleur

	int posX; // Son ordonnée dans l'aire de jeu
	int posY; // Son abscisse dans l'aire de jeu

public:
	Piece();
	Piece(int k, int o);
	Piece(const Piece &p);

	void setKind(int k);
	void setOrientation(int o);

	int getKind();
	int getOrientation();

	void setColor(int c);
	int getColor();

	void setPosX(int x);
	int getPosX();

	void setPosY(int y);
	int getPosY();
};

#endif

Cette classe est ridiculement simple, elle ne contient que 2 constructeurs, un constructeur de copie et des méthodes d’écriture et de lecture de ses attributs (getters, setters).

Son implémentation est toute aussi bête.

/* piece.cpp */
#include "piece.h"

Piece::Piece()
{
    // Rien à faire
}

Piece::Piece(int k, int o)
{
	kind = k;
	orientation = o;
}

Piece::Piece(const Piece &p)
{
	kind = p.kind;
	orientation = p.orientation;
	color = p.color;
	posX = p.posX;
	posY = p.posY;
}

void Piece::setKind(int k)
{
	kind = k;
}

void Piece::setOrientation(int o)
{
	orientation = o;
}

int Piece::getKind()
{
	return kind;
}

int Piece::getOrientation()
{
	return orientation;
}

void Piece::setColor(int c)
{
	color = c;
}

int Piece::getColor()
{
	return color;
}

void Piece::setPosX(int x)
{
	posX = x;
}

int Piece::getPosX()
{
	return posX;
}

void Piece::setPosY(int y)
{
	posY = y;
}

int Piece::getPosY()
{
	return posY;
}

Voilà, c’en est fini pour la pièce, on passe à l’aire de jeu

Board

Le Tetris classique comporte une aire de 20 lignes et 10 colonnes. Nous allons la représenter par un tableau à 2 dimensions de 20 lignes et 10 colonnes.


Sur le schéma, les X représentent les colonnes et les Y les lignes.
La zone grise du tableau représente la zone où la pièce sera spawnée et le point noir dans cette zone représente la position du bloc de pivot de la pièce, il a pour coordonnée (0, 5).

Voici le squelette de la classe Board. Il sera rempli au fur et à mesure de notre périple.

/* board.h */
#include "piece.h"

/*
* FREE : indique que le point du tableau est vide
* FILLED: indique que le point du tableau contient un bloc (1 ou 2)
*/
enum  { FREE,  FILLED };

const int BOARD_HEIGHT = 20; // La hauteur de l'aire de jeu
const int BOARD_WIDTH = 10; // Sa largeur

const int ORIGIN_X = 0; // L'ordonnée du point de pivot
const int ORIGIN_Y = 5; // Son absisse

class Board
{
private:
	Piece currentPiece; // La pièce courante se trouvant sur l'aire de jeu

public:
	int area[BOARD_WIDTH][BOARD_HEIGHT]; // Le tableau représentant l'aire de jeu

	Board();

        void setCurrentPiece(Piece p);
        Piece getCurrentPiece();
};

#endif
/* board.cpp */
Board::Board()
{
       /* On initialise toutes les cases de l'aire de jeu à FREE (0) */
	for(int i = 0; i < BOARD_WIDTH; ++i)
		for(int j = 0; j < BOARD_HEIGHT; ++j)
			area[i][j] = FREE;
}

void Board::setCurrentPiece(Piece p)
{
    currentPiece = p;
}

Piece Board::getCurrentPiece()
{
    return currentPiece;
}

Dessiner une pièce sur l’aire de jeu

Comment allons nous ajouter une pièce donnée sur l’aire de jeu ? On pourrait par exemple remplir le tableau area en le parcourant simultanément avec la matrice de la pièce et en mettant dans chaque case, la valeur correspondante dans la matrice de la pièce. Une méthode plus simple (enfin, … au bout du compte, elle s’avèrera être la plus simple ) existe, le Flood Fill.

Flood Fill ?

Le flood fill ou algorithme de remplissage par diffusion (ben oui, c’est un blog sur les algorithmes à la base) est un algorithme qui détermine toutes les cases connectées à une case origine dans un tableau multidimensionnel et les remplit d’une valeur donnée. Ici, « connectées » veut dire qui ont la même valeur. En gros, il permet de détecter des formes dans un tableau en fonction d’une caractéristique commune que certaines cases du tableau partagent.
Voici par exemple, le fonctionnement d’un flood fill qui cherche dans 4 directions (haut, bas, gauche et droite)


Ici, le flood fill commence par la case au centre et détecte toutes les autres cases blanches qui lui sont directement connectés. Comme vous pouvez le voir, il ne détecte pas les autres cases blanches car celles ci sont séparées des autres par des cases noires. C’est un algorithme largement utilisé en infographie qui change la couleur d’un ensemble connexe de pixels de même couleur et délimité par des contours.
Il se retrouve également dans beaucoup de jeux comme le démineur. A ce propos, il y a un excellent problème sur UVa qui nécessite l’utilisation du flood fill pour résoudre un mini jeu de démineur. Jetez y un coup d’oeil.

Voici une implémentation classique du flood fill en C++.

const int N = 10; // La taille du tableau;
bool visited[N][N] = { {false} }; // Pour marquer les cases du tableau qu'on a déjà visitées
int a[N][N]; // Le tableau sur lequel faire le flood fill

/*
* i: L'absisse de la case à partir de laquelle "floodfiller"
* j: L'ordonnée de la case à partir de laquelle "floodfiller"
* val: La valeur que les cases à découvrir ont en commun
*/
void flood(int i, int j, int val)
{
 /* Si on dépasse les limites du tableau, ou si la case en cours a déjà
  * été visitée ou si cette case ne contient pas la valeur que nous re-
  * cherchons, ...
  */
    if(i < 0 || i > N || j < 0 || j > N || visited[i][j] || a[i][j] != val)
        return; // ... on quitte la fonction

    visited[i][j] = true; // Si non, on marque cette case comme visitée

    flood(i, j - 1, val); // Et on cherche vers le haut, ...
    flood(i, j + 1, val); // vers le bas
    flood(i - 1, j, val); // à gauche
    flood(i + 1, j, val); // et à droite
}

Revenons à nos moutons

Comment on va utiliser le flood fill pour dessiner nos pièces? C’est simple, on va faire un seul flood fill mais qui parcoure 2 tableaux à la fois. Pendant qu’il est en train de découvrir la forme de la pièce, il remplit en même temps le tableau aire en fonction des informations qu’il recueille dans la matrice de la pièce.
Le code sera certainement plus parlant pour vous.

Ajoutez ces prototypes dans  board.h et leurs implémentations dans le .cpp correspondant.

void flood(int i, int j, int px, int py, int k, int o, int value, bool visited[][SIZE]);
void floodFill(int i, int j, int px, int py, int k, int o, int value);
/*
* (i, j): Les coordonnées de la case à partir de laquelle floodfiller dans l'aire de jeu
* (px, py): Les coordonnées de la case à partir de laquelle floodfiller dans la matrice de la pièce
* k: pour kind (type), le type de la pièce
* o: pour orientation, l'orientation de la pièce
* value: valeur avec laquelle remplir l'aire de jeu
*/
void Board::flood(int i, int j, int px, int py, int k, int o, int value, bool visited[][SIZE])
{
	if(px < 0 || px >= SIZE || py < 0 || py >= SIZE || visited[px][py] || PIECES[k][o][px][py] == FREE)
		return;

	visited[px][py] = true;
	area[j][i] = value; // On remplit la case de la valeur dans l'aire

	flood(i, j - 1, px, py - 1, k, o, value, visited);
	flood(i + 1, j, px + 1, py, k, o, value, visited);
	flood(i, j + 1, px, py + 1, k, o, value, visited);
	flood(i - 1, j, px - 1, py, k, o, value, visited);
}

/* Cette fonction ne fait qu'appeler le flood */
void Board::floodFill(int i, int j, int px, int py, int k, int o, int value)
{
	bool visited[SIZE][SIZE];

	for(int l = 0; l < SIZE; ++l)
		for(int m = 0; m < SIZE; ++m)
			visited[l][m] = false;

	flood(i, j, px, py, k, o, value, visited);
}

Ouf!, maintenant que ceci est fait, le reste n’est qu’une partie de rigolade (enfin presque).
On peut maintenant faire notre  méthode de dessin de la pièce. Elle prend en paramètre un objet de type Piece et appelle notre méthode floodFill() pour dessiner la pièce sur l’aire de jeu.

Ajoutez ce prototype dans board.h

void drawPiece(Piece p);

Voici l’implémentation

void Board::drawPiece(Piece p)
{
        int i = p.getPosX(); // On récupère les ...
	int j = p.getPosY(); // ... coordonnées de la pièce

	int k = p.getKind(); // On récupère son type
	int o = p.getOrientation(); // et sa rotation

	switch(k) // En fonction de son type
	{
		case I:
			p.setColor(CYAN); // On lui affecte la couleur appropriée
			break;
		case J:
			p.setColor(BLUE);
			break;
		case L:
			p.setColor(ORANGE);
			break;
		case O:
			p.setColor(YELLOW);
			break;
		case S:
			p.setColor(GREEN);
			break;
		case T:
			p.setColor(PURPLE);
			break;
		case Z:
			p.setColor(RED);
			break;
		default:
			break;
	}

     /* On fait le flood fill à partir du point de pivot de la pièce
      * et on remplir l'aire de jeu en fonction de la couleur de la pièce
      */
       floodFill(i, j, PIVOT_X, PIVOT_Y, k, o, p.getColor());

Il nous faudrait aussi une fonction pour effacer une pièce donnée de l’aire de jeu.
Voici son prototype :

void clearPiece(Piece p);

Il suffit simplement d’appeler le flood fill avec comme valeur de remplissage FREE (c-à-d 0). Je vous avais bien dit qu’à partir de maintenant, ce n’est plus que de la rigolade.

void Board::clearPiece(Piece p)
{
	int i = p.getPosX();
	int j = p.getPosY();

	int k = p.getKind();
	int o = p.getOrientation();

	floodFill(i, j, PIVOT_X, PIVOT_Y, k, o, FREE);
}

Maintenant, il nous faut une méthode pour spawner une nouvelle pièce tout en haut de l’aire de jeu comme illustrée à la figure montrée plus haut. Dans cette fonction, il nous suffira d’appeler drawPiece(Piece p)

Le prototype

void newPiece(Piece p);

L’implémentation

void Board::newPiece(Piece p)
{
	p.setPosX(ORIGIN_X); // On donne à la pièce les coordonnées ...
	p.setPosY(ORIGIN_Y); // de l'origine

	drawPiece(p); // On la dessine

	setCurrentPiece(p); // On déclare cette pièce comme pièce courante de l'aire de jeu
}

On pourrait aussi ajouter une méthode clear() qui reinitialise toute l’aire de jeu. Elle pourrait être appelée, par exemple, après un game over, si le joueur voudrait rejouer.

void clear();
void Board::clear()
{
	for(int i = 0; i < BOARD_WIDTH; ++i)
	{
		for(int j = 0; j < BOARD_HEIGHT; ++j)
			area[i][j] = FREE;
	}
}

Voilà, je crois que maintenant, les fondations sont posées, nous pouvons passer à la suite. Mais avant, un petit récapitulatif, voici ce à quoi devraient ressembler nos fichiers board.h et board.cpp

/* board.h */
#ifndef BOARD_H
#define BOARD_H

#include "piece.h"

enum  { FREE, FILLED };

const int BOARD_HEIGHT = 20;
const int BOARD_WIDTH = 10;

const int ORIGIN_X = 0;
const int ORIGIN_Y = 5;

class Board
{
private:
	Piece currentPiece;
	Piece ghostPiece;

	void flood(int i, int j, int px, int py, int k, int o, int value, bool visited[][SIZE]);
	void floodFill(int i, int j, int px, int py, int k, int o, int value);

public:
	int area[BOARD_WIDTH][BOARD_HEIGHT];

	Board();

	void setCurrentPiece(Piece p);
	Piece getCurrentPiece();

	void drawPiece(Piece p);
	void clearPiece(Piece p);

	void newPiece(Piece p);

	void clear();
};

#endif
/* board.cpp */
#include "board.h"

void Board::flood(int i, int j, int px, int py, int k, int o, int value, bool visited[][SIZE])
{
	if(px < 0 || px >= SIZE || py < 0 || py >= SIZE || visited[px][py] || PIECES[k][o][px][py] == FREE)
		return;

	visited[px][py] = true;
	area[j][i] = value;

	flood(i, j - 1, px, py - 1, k, o, value, visited);
	flood(i + 1, j, px + 1, py, k, o, value, visited);
	flood(i, j + 1, px, py + 1, k, o, value, visited);
	flood(i - 1, j, px - 1, py, k, o, value, visited);
}

void Board::floodFill(int i, int j, int px, int py, int k, int o, int value)
{
	bool visited[SIZE][SIZE];

	for(int l = 0; l < SIZE; ++l)
		for(int m = 0; m < SIZE; ++m)
			visited[l][m] = false;

	flood(i, j, px, py, k, o, value, visited);
}

Board::Board()
{
	for(int i = 0; i < BOARD_WIDTH; ++i)
		for(int j = 0; j < BOARD_HEIGHT; ++j)
			area[i][j] = FREE;
}

void Board::drawPiece(Piece p)
{
	int i = p.getPosX();
	int j = p.getPosY();

	int k = p.getKind();
	int o = p.getOrientation();

	switch(k)
	{
		case I:
			p.setColor(CYAN);
			break;
		case J:
			p.setColor(BLUE);
			break;
		case L:
			p.setColor(ORANGE);
			break;
		case O:
			p.setColor(YELLOW);
			break;
		case S:
			p.setColor(GREEN);
			break;
		case T:
			p.setColor(PURPLE);
			break;
		case Z:
			p.setColor(RED);
			break;
		default:
			break;
	}

	floodFill(i, j, PIVOT_X, PIVOT_Y, k, o, p.getColor());
}

void Board::clearPiece(Piece p)
{
	int i = p.getPosX();
	int j = p.getPosY();

	int k = p.getKind();
	int o = p.getOrientation();

	floodFill(i, j, PIVOT_X, PIVOT_Y, k, o, FREE);
}

void Board::newPiece(Piece p)
{
	p.setPosX(ORIGIN_X);
	p.setPosY(ORIGIN_Y);

	drawPiece(p);

	setCurrentPiece(p);
}

void Board::clear()
{
	for(int i = 0; i < BOARD_WIDTH; ++i)
	{
		for(int j = 0; j < BOARD_HEIGHT; ++j)
			area[i][j] = FREE;
	}
}

Donnons du mouvement à nos pièces

Les collisions
Avant de pouvoir bouger ou tourner notre pièce, nous devons d’abord gérer les collisions. Par exemple, si on veut déplacer la pièce à gauche mais qu’on est déjà à la limite gauche de l’aire de jeu, le déplacement est impossible. De même, si on veut déplacer la pièce vers le bas et qu’il y a déjà des blocs stockés là, la pièce ne pourra pas être déplacée. Bref, la pièce peut entrer en collision soit avec les limites de l’aire de jeu, soit avec des blocs déjà stockés dans l’aire de jeu.

Pour gérer les collisions, plutôt que de nous embourber dans de multiples conditions, on va encore une fois profiter de notre flood fill. On va surdéfinir (merci à la POO) notre méthode flood() pour y ajouter une référence à un booléen qui prendre la valeur vrai si la pièce peut-être bougée et faux si non.

void flood(int i, int j, int px, int py, int k, int o, bool &flag, bool visited[][SIZE]);
void Board::flood(int i, int j, int px, int py, int k, int o, bool &flag, bool visited[][SIZE])
{
	if(px < 0 || px >= SIZE || py < 0 || py >= SIZE || visited[px][py] || PIECES[k][o][px][py] == FREE)
		return;

	visited[px][py] = true;

   /* Si on dépasse les limites de l'aire de jeu
    * ou si la case sur laquelle on est n'est pas vide
    */
	if(i < 0 || i >= BOARD_HEIGHT || j < 0 || j >= BOARD_WIDTH || area[j][i] != FREE)
	{
		flag = false; // on met flag à false
		return;
	}

	flood(i, j - 1, px, py - 1, k, o, flag, visited);
	flood(i + 1, j, px + 1, py, k, o, flag, visited);
	flood(i, j + 1, px, py + 1, k, o, flag, visited);
	flood(i - 1, j, px - 1, py, k, o, flag, visited);
}

Cette méthode est un flood fill basique, simplement que cette fois-ci, on teste si on n’est pas en dehors des limites de l’aire de jeu, ni qu’on entre en collision avec un autre bloc déjà stocké dans l’aire.

On peut maintenant faire une méthode qui teste si une pièce peut être bougée. Voici son prototype :

bool isCurrentPieceMovable(int x, int y);

L’implémentation

/* Peut on déplacer la pièce courante de sa position vers la position (x, y) ? */
bool Board::isCurrentPieceMovable(int x, int y)
{
	clearPiece(currentPiece); // D'abord on efface la pièce courante

	bool movable = true; // On suppose au départ qu'on peut bouger la pièce

   /* On déclare et initialise le tableau visited pour le flood fill */
	bool visited[SIZE][SIZE];

	for(int l = 0; l < SIZE; ++l)
		for(int m = 0; m < SIZE; ++m)
			visited[l][m] = false;

	int k = currentPiece.getKind(); // On récupère le type ...
	int o = currentPiece.getOrientation(); // ... et l'orientation de la pièce

   /* On fait notre flood fill */
	flood(x, y, PIVOT_X, PIVOT_Y, k, o, movable, visited);

	drawPiece(currentPiece); // On redessine notre pièce

	return movable; // On renvoie le résultat
}

Ici, on a d’abord besoin d’effacer la pièce courante avant de faire le flood fill, si non, la méthode renverrait probablement toujours la valeur false car on risquerait d’entrer toujours en collision avec les blocs de la pièce courante.

De même, on peut tester si on peut tourner la pièce courante dans un sens ou dans l’autre.

bool isCurrentPieceRotable(int o);
/* Peut on affecter l'orientation o à la pièce courante ? */
bool Board::isCurrentPieceRotable(int o)
{
	clearPiece(currentPiece);

	bool rotable = true;

	bool visited[SIZE][SIZE];

	for(int i = 0; i < SIZE; ++i)
		for(int j = 0; j < SIZE; ++j)
			visited[i][j] = false;

	int k = currentPiece.getKind();

	flood(currentPiece.getPosX(), currentPiece.getPosY(), PIVOT_X, PIVOT_Y, k, o, rotable, visited);

	drawPiece(currentPiece);

	return rotable;
}

Ce code est quasiment le même que le précédent.

Déplaçons notre pièce

Maintenant que la gestion des collisions est faite, nous pouvons librement déplacer notre pièce, en bas, à droite et à gauche, ceci avec 3 méthodes dont voici les prototypes :

void moveCurrentPieceDown();
void moveCurrentPieceLeft();
void moveCurrentPieceRight();

Voici leurs implémentations

/* Déplace la pièce d'une case vers le bas */
void Board::moveCurrentPieceDown()
{
	int x = currentPiece.getPosX();
	int y = currentPiece.getPosY();

	if(isCurrentPieceMovable(x + 1, y)) // Si on peut bouger la pièce vers le bas
	{
		clearPiece(currentPiece); // On efface la pièce de son ancienne position
		currentPiece.setPosX(x + 1); // On incrémente son ordonnée

		drawPiece(currentPiece); // On la redessine à la nouvelle position
	}
}

/* Déplace la pièce d'une case vers la gauche */
void Board::moveCurrentPieceLeft()
{
	int x = currentPiece.getPosX();
	int y = currentPiece.getPosY();

	if(isCurrentPieceMovable(x, y - 1))
	{
		clearPiece(currentPiece);
		currentPiece.setPosY(y - 1);

		drawPiece(currentPiece);
	}
}

/* Déplace la pièce d'une case vers la droite */
void Board::moveCurrentPieceRight()
{
	int x = currentPiece.getPosX();
	int y = currentPiece.getPosY();

	if(isCurrentPieceMovable(x, y + 1))
	{
		clearPiece(currentPiece);
		currentPiece.setPosY(y + 1);

		drawPiece(currentPiece);
	}
}

Tournons notre pièce

On peut également maintenant la tourner à droite ou à gauche avec ces méthodes :

 void rotateCurrentPieceLeft();
 void rotateCurrentPieceRight();
/* Tourne la pièce vers la gauche */
void Board::rotateCurrentPieceLeft()
{
	int o = currentPiece.getOrientation(); // On récupère l'orientation courante

	if(o > 0) // Si on n'est pas sur la 1ère orientation
		o--; // On peut décrémenter o
	else // Si non
		o = NB_ROTATIONS - 1; // On passe à la dernière orientation

	if(isCurrentPieceRotable(o)) // Si on peut tourner la pièce
	{
		clearPiece(currentPiece); // On efface la pièce courante

		currentPiece.setOrientation(o); // On change son orientation
		drawPiece(currentPiece); // On la redessine avec la nouvelle orientation
	}
}

/* Tourne la pièce vers la droite */
void Board::rotateCurrentPieceRight()
{
	int o = currentPiece.getOrientation();

	if(o < NB_ROTATIONS - 1) // Si on n'est pas sur la dernière orientation
		o++; // On peut incrémenter o
	else // Si non
		o = 0; // On passe à la 1ère orientation

	if(isCurrentPieceRotable(o))
	{
		clearPiece(currentPiece);

		currentPiece.setOrientation(o);
		drawPiece(currentPiece);
	}
}

Et voilà, notre pièce peut maintenant bouger, tourner, « naviguer dans l’aire de jeu ».
Pour clore la classe Board, il nous reste encore 5 méthodes à écrire, mais rassurez, ces dernières sont plus simples que les précédentes.

  • Généralement, dans le jeu Tetris, les lignes pleines (remplies totalement de blocs) sont supprimées, et les lignes se trouvant au dessus descendent d’une case vers le bas. Parmi ces 5 méthodes, on en aura une pour faire çà.
  • Une autre qui scanne l’aire pour voir si il y a des lignes pleines et qui appelle la méthode précédente.
  • Le Hard Drop dans le jeu Tetris est le fait de faire tomber brutalement la pièce en appuyant sur une touche (en général Espace). Une autre méthode y est dédiée.
  • 2 dernières méthodes, l’une pour tester si la pièce courante est définitevement tombée et l’autre pour tester si on a un Game Over.

Allez, on s’accroche

void deleteLine(int y); // Supprime la ligne se trouvant à la position y
int deletePossibleLines(); // Vérifie s'il existe des lignes pleines et les supprime
void dropCurrentPiece(); // Hard Drop
bool isCurrentPieceFallen(); // La pièce courante est-elle tombée ?
bool isGameOver(); // Le nom de la méthode est assez claire

Voici leurs implémentations, rien de bien difficile :

void Board::deleteLine(int y)
{
	clearPiece(currentPiece); // On efface d'abord la pièce courante

        /* On déplace toutes les lignes à partir de y vers le haut
         * d'une case vers le bas
         */
	for(int j = y; j > 0; --j)
	{
		for(int i = 0; i < BOARD_WIDTH; ++i)
			area[i][j] = area[i][j-1];
	}

	drawPiece(currentPiece); // On la redessine
}

/* Renvoie le nombre de lignes supprimées */
int Board::deletePossibleLines()
{
	int nbLinesDeleted = 0;

	for(int j = 0; j < BOARD_HEIGHT; ++j)
	{
		int i = 0;

		for(; i < BOARD_WIDTH && area[i][j] != FREE; ++i);

		if(i == BOARD_WIDTH) // On a trouvé une ligne pleine
		{
			nbLinesDeleted++; // On incrémente le nombre de lignes supprimées
			deleteLine(j); // On supprime la ligne
		}
	}

	return nbLinesDeleted;
}

/* HARD-DROP */
void Board::dropCurrentPiece()
{
	int x = currentPiece.getPosX();
	int y = currentPiece.getPosY();

	while(isCurrentPieceMovable(x++, y)) // Tant qu'on peut toujours bouger la pièce vers le bas
		moveCurrentPieceDown(); // on le fait
}

/* Teste si la pièce courante est tombée donc ne peut plus bouger */
bool Board::isCurrentPieceFallen()
{
	int x = currentPiece.getPosX();
	int y = currentPiece.getPosY();

	if(isCurrentPieceMovable(x + 1, y)) // Si on peut encore la bouger vers le bas
		return false; // on renvoie faux

	return true; // si non on renvoie vrai
}

bool Board::isGameOver()
{
	for(int i = 0; i < BOARD_WIDTH; ++i)
	{
		if(area[i][0] != FREE) // Si il y a un bloc sur la première ligne de l'aire
			return true; // C'est que la partie est finie
	}

	return false;
}

Voici donc l’apparence finale que devraient avoir nos fichers board.h et board.cpp :

/* board.h */
#ifndef BOARD_H
#define BOARD_H

#include "piece.h"

enum  { FREE, FILLED };

const int BOARD_HEIGHT = 20;
const int BOARD_WIDTH = 10;

const int ORIGIN_X = 0;
const int ORIGIN_Y = 5;

class Board
{
private:
	Piece currentPiece;
	Piece ghostPiece;

	void flood(int i, int j, int px, int py, int k, int o, int value, bool visited[][SIZE]);
    void flood(int i, int j, int px, int py, int k, int o, bool &flag, bool visited[][SIZE]);
	void floodFill(int i, int j, int px, int py, int k, int o, int value);

public:
	int area[BOARD_WIDTH][BOARD_HEIGHT];

	Board();

	void setCurrentPiece(Piece p);
	Piece getCurrentPiece();

	void drawPiece(Piece p);
	void clearPiece(Piece p);

	void newPiece(Piece p);

    bool isCurrentPieceMovable(int x, int y);
	bool isCurrentPieceRotable(int o);

	void moveCurrentPieceDown();
	void moveCurrentPieceLeft();
	void moveCurrentPieceRight();

	void rotateCurrentPieceLeft();
	void rotateCurrentPieceRight();

	void deleteLine(int y);
	int deletePossibleLines();

	void dropCurrentPiece();
	bool isCurrentPieceFallen();

	void clear();
};

#endif
/* board.cpp */
#include "board.h"

void Board::flood(int i, int j, int px, int py, int k, int o, int value, bool visited[][SIZE])
{
	if(px < 0 || px >= SIZE || py < 0 || py >= SIZE || visited[px][py] || PIECES[k][o][px][py] == FREE)
		return;

	visited[px][py] = true;
	area[j][i] = value;

	flood(i, j - 1, px, py - 1, k, o, value, visited);
	flood(i + 1, j, px + 1, py, k, o, value, visited);
	flood(i, j + 1, px, py + 1, k, o, value, visited);
	flood(i - 1, j, px - 1, py, k, o, value, visited);
}

void Board::flood(int i, int j, int px, int py, int k, int o, bool &flag, bool visited[][SIZE])
{
	if(px < 0 || px >= SIZE || py < 0 || py >= SIZE || visited[px][py] || PIECES[k][o][px][py] == FREE)
		return;

	visited[px][py] = true;

	if(i < 0 || i >= BOARD_HEIGHT || j < 0 || j >= BOARD_WIDTH || area[j][i] != FREE )
	{
		flag = false;
		return;
	}

	flood(i, j - 1, px, py - 1, k, o, flag, visited);
	flood(i + 1, j, px + 1, py, k, o, flag, visited);
	flood(i, j + 1, px, py + 1, k, o, flag, visited);
	flood(i - 1, j, px - 1, py, k, o, flag, visited);
}

void Board::floodFill(int i, int j, int px, int py, int k, int o, int value)
{
	bool visited[SIZE][SIZE];

	for(int l = 0; l < SIZE; ++l)
		for(int m = 0; m < SIZE; ++m)
			visited[l][m] = false;

	flood(i, j, px, py, k, o, value, visited);
}

Board::Board()
{
	for(int i = 0; i < BOARD_WIDTH; ++i)
		for(int j = 0; j < BOARD_HEIGHT; ++j)
			area[i][j] = FREE;
}

void Board::drawPiece(Piece p)
{
	int i = p.getPosX();
	int j = p.getPosY();

	int k = p.getKind();
	int o = p.getOrientation();

	switch(k)
	{
		case I:
			p.setColor(CYAN);
			break;
		case J:
			p.setColor(BLUE);
			break;
		case L:
			p.setColor(ORANGE);
			break;
		case O:
			p.setColor(YELLOW);
			break;
		case S:
			p.setColor(GREEN);
			break;
		case T:
			p.setColor(PURPLE);
			break;
		case Z:
			p.setColor(RED);
			break;
		default:
			break;
	}

	floodFill(i, j, PIVOT_X, PIVOT_Y, k, o, p.getColor());
}

void Board::clearPiece(Piece p)
{
	int i = p.getPosX();
	int j = p.getPosY();

	int k = p.getKind();
	int o = p.getOrientation();

	floodFill(i, j, PIVOT_X, PIVOT_Y, k, o, FREE);
}

void Board::newPiece(Piece p)
{
	p.setPosX(ORIGIN_X);
	p.setPosY(ORIGIN_Y);

	drawPiece(p);

	setCurrentPiece(p);
}

bool Board::isCurrentPieceMovable(int x, int y)
{
	clearPiece(currentPiece);

	bool movable = true;

	bool visited[SIZE][SIZE];

	for(int l = 0; l < SIZE; ++l)
		for(int m = 0; m < SIZE; ++m)
			visited[l][m] = false;

	int k = currentPiece.getKind();
	int o = currentPiece.getOrientation();

	flood(x, y, PIVOT_X, PIVOT_Y, k, o, movable, visited);

	drawPiece(currentPiece);

	return movable;
}

bool Board::isCurrentPieceRotable(int o)
{
	clearPiece(currentPiece);

	bool rotable = true;

	bool visited[SIZE][SIZE];

	for(int i = 0; i < SIZE; ++i)
		for(int j = 0; j < SIZE; ++j)
			visited[i][j] = false;

	int k = currentPiece.getKind();

	flood(currentPiece.getPosX(), currentPiece.getPosY(), PIVOT_X, PIVOT_Y, k, o, rotable, visited);

	drawPiece(currentPiece);

	return rotable;
}

void Board::moveCurrentPieceDown()
{
	int x = currentPiece.getPosX();
	int y = currentPiece.getPosY();

	if(isCurrentPieceMovable(x + 1, y))
	{
		clearPiece(currentPiece);
		currentPiece.setPosX(x + 1);

		drawPiece(currentPiece);
	}
}

void Board::moveCurrentPieceLeft()
{
	int x = currentPiece.getPosX();
	int y = currentPiece.getPosY();

	if(isCurrentPieceMovable(x, y - 1))
	{
		clearPiece(currentPiece);
		currentPiece.setPosY(y - 1);

		drawPiece(currentPiece);
	}
}

void Board::moveCurrentPieceRight()
{
	int x = currentPiece.getPosX();
	int y = currentPiece.getPosY();

	if(isCurrentPieceMovable(x, y + 1))
	{
		clearPiece(currentPiece);
		currentPiece.setPosY(y + 1);

		drawPiece(currentPiece);
	}
}

void Board::rotateCurrentPieceLeft()
{
	int o = currentPiece.getOrientation();

	if(o > 0)
		o--;
	else
		o = NB_ROTATIONS - 1;

	if(isCurrentPieceRotable(o))
	{
		clearPiece(currentPiece);

		currentPiece.setOrientation(o);
		drawPiece(currentPiece);
	}
}

void Board::rotateCurrentPieceRight()
{
	int o = currentPiece.getOrientation();

	if(o < NB_ROTATIONS - 1)
		o++;
	else
		o = 0;

	if(isCurrentPieceRotable(o))
	{
		clearPiece(currentPiece);
		currentPiece.setOrientation(o);

		drawPiece(currentPiece);
	}
}

void Board::deleteLine(int y)
{
	clearPiece(currentPiece);

	for(int j = y; j > 0; --j)
	{
		for(int i = 0; i < BOARD_WIDTH; ++i)
			area[i][j] = area[i][j-1];
	}

	drawPiece(currentPiece);
}

int Board::deletePossibleLines()
{
	int nbLinesDeleted = 0;

	for(int j = 0; j < BOARD_HEIGHT; ++j)
	{
		int i = 0;

		for(; i < BOARD_WIDTH && area[i][j] != FREE; ++i);

		if(i == BOARD_WIDTH)
		{
			nbLinesDeleted++;
			deleteLine(j);
		}
	}

	return nbLinesDeleted;
}

void Board::dropCurrentPiece()
{
	int x = currentPiece.getPosX();
	int y = currentPiece.getPosY();

	while(isCurrentPieceMovable(x++, y))
		moveCurrentPieceDown();
}

bool Board::isCurrentPieceFallen()
{
	int x = currentPiece.getPosX();
	int y = currentPiece.getPosY();

	if(isCurrentPieceMovable(x + 1, y))
		return false;

	return true;
}

bool Board::isGameOver()
{
	for(int i = 0; i < BOARD_WIDTH; ++i)
	{
		if(area[i][0] != FREE)
			return true;
	}

	return false;
}

void Board::clear()
{
	for(int i = 0; i < BOARD_WIDTH; ++i)
	{
		for(int j = 0; j < BOARD_HEIGHT; ++j)
			area[i][j] = FREE;
	}
}

TERMINÉ.
Félicitations si vous avez tenu jusque là, le plus dur est fait. Assurez vous d’avoir bien compris l’implémentation de la classe Board, c’est en quelque sorte le « coeur » de notre Tetris. Si vous voulez plus d’explications sur un point, si vous avez des remarques, si vous avez décellé des erreurs ou si vous voulez simplement laisser une trace de votre lecture, laissez un commentaire.

Comme je le disais, le « coeur » ou le « noyau » (tout de suite, les grands mots) de notre jeu est fini, il ne nous reste plus qu’à gérer le rendu et le score, rendez-vous à la partie 2.

A plus, n’oubliez pas, laissez un commentaire.

  1. Florian
    4 février 2011 à 01:17

    Vivement la partie 2…

    • 4 février 2011 à 09:52

      Elle arrive très bientôt, n’hésites pas à t’abonner au flux RSS pour être informé dès sa publication.

  2. lenoir
    10 mars 2011 à 06:26

    Genial, a quand la partie2.Merci

    • 16 mars 2011 à 14:59

      Merci, je vais essayer de la mettre en ligne dès que possible surtout que je vois que çà intéresse du monde.

  3. lenoir
    29 mars 2011 à 18:29

    tres bien ,il faudrait mettre le code complet

  4. gomi
    1 avril 2011 à 14:02

    Du nouveau? j’aimerais bien voir la deuxième partie

  5. Choupette
    11 avril 2011 à 22:00

    Troooo bien fait, so useful, vivement la partie 2.

    Merci d’avoir réalisé ce tuto 🙂

  6. stargks
    12 avril 2011 à 11:35

    whouaaaa c le meilleur tuto sur le devellopement du tetris en c++ que j’ai vue …
    goog job
    j’attend avec impacience la partie 2.
    sinon pour la fonction drawpiece() je vois pas ou tu declare les varible T et autre que tu utilise dans ton switch

    • 12 avril 2011 à 15:04

      Merci, dans le switch de la méthode drawPiece(), j’utilise une variable k et non T, elle représente le type de la pièce

  7. gomi
    20 avril 2011 à 19:49

    Toujours pas de partie 2

  8. prada440
    21 juin 2011 à 01:05

    Salut, Je viens de terminer mon tetris graphique (SDL) en langage C en 4 jours de programmation non stop :p et pour l’améliorer j’ai besoin de gérer le calcul du score. jusqu’ici jai réussi a lier le score au niveau mais il me manque le bonus(si on efface 2 ou 3 ou 4 lignes a la fois)Donc j’attend avec impatience votre deuxième partie pour pouvoir optimiser mon jeu et me lancer après au C++ pour le refaire en utilisant le POO et si je coince biensur je n’hésiterai pas à jeter un oeil sur votre solution.
    p.s: j’avais fait un démineur en C toujours en SDL avant d’entamer tetris et qui est super bien coté graphique je pense, donc vivement la 2ème partie :p

    • 21 juin 2011 à 13:50

      Salut, voici la fonction qui calcule le score dans mon code

      int Game::computeScore(int nbLinesDeleted)
      {
          int level = getLevel(); // Le niveau actuel
          int score = 0;
      
          switch(nbLinesDeleted)
          {
              case 1:
                  score = 40 * (level + 1);
                  break;
              case 2:
                  score = 100 * (level + 1);
                  break;
              case 3:
                  score = 300 * (level + 1);
                  break;
              case 4:
                   score = 1200 * (level + 1);
                   break;
               default:
                   break;
          }
      
          return score;
      }
      

      C’est une fonction assez simple qui calcule le score en fonction du niveau et du nombre de lignes supprimées.
      J’espère que çà t’aidera.

  1. No trackbacks yet.

Laisser un commentaire