Mis à jour le mercredi 21 juin 2017
  • 40 heures
  • Moyenne

Ce cours est visible gratuitement en ligne.

Ce cours existe en livre papier.

Ce cours existe en eBook.

Vous pouvez obtenir un certificat de réussite à l'issue de ce cours.

Vous pouvez être accompagné et mentoré par un professeur particulier par visioconférence sur ce cours.

J'ai tout compris !

TP : Mario Sokoban

Connectez-vous ou inscrivez-vous pour bénéficier de toutes les fonctionnalités de ce cours !

La bibliothèque SDL fournit, comme vous l'avez vu, un grand nombre de fonctions prêtes à l'emploi. Il est facile de s'y perdre les premiers temps, surtout si on ne pratique pas.

Ce premier TP de la section SDL est justement là pour vous faire découvrir un cas pratique et surtout vous inviter à manipuler. Cette fois, vous l'aurez compris je crois, notre programme ne sera pas une console mais bel et bien une fenêtre !

Quel va être le sujet de ce TP ? Il va s'agir d'un jeu de Sokoban !
Peut-être que ce nom ne vous dit rien, mais le jeu est pourtant un grand classique des casse-têtes. Il consiste à pousser des caisses pour les amener à des points précis dans un labyrinthe.

Cahier des charges du Sokoban

À propos du Sokoban

« Sokoban » est un terme japonais qui signifie « Magasinier ».
Il s'agit d'un casse-tête inventé dans les années 80 par Hiroyuki Imabayashi. Le jeu a remporté un concours de programmation à cette époque.

Le but du jeu

Il est simple à comprendre : vous dirigez un personnage dans un labyrinthe. Il doit pousser des caisses pour les amener à des endroits précis. Le joueur ne peut pas déplacer deux caisses à la fois.

Si le principe se comprend vite et bien, cela ne veut pas dire pour autant que le jeu est toujours facile. Il est en effet possible de réaliser des casse-têtes vraiment… prise de tête !

La fig. suivante vous donne un aperçu du jeu que nous allons réaliser.

Le jeu Mario Sokoban que nous allons réaliser
Pourquoi avoir choisi ce jeu ?

Parce que c'est un jeu populaire, qu'il fait un bon sujet de programmation et qu'on peut le réaliser avec les connaissances que l'on a acquises.
Alors bien sûr, ça demande de l'organisation. La difficulté n'est pas vraiment dans le code lui-même mais dans l'organisation. Il va en effet falloir découper notre programme en plusieurs fichiers.cintelligemment et essayer de créer les bonnes fonctions.

C'est aussi pour cette raison que j'ai décidé cette fois de ne pas construire le TP comme les précédents : je ne vais pas vous donner des indices suivis d'une correction à la fin. Au contraire, je vais vous montrer comment je réalise tout le projet de A à Z.

Et si je veux m'entraîner tout seul ?

Pas de problème ! Allez-y lancez-vous, c'est même très bien !
Il vous faudra certainement un peu de temps : personnellement ça m'a pris une bonne petite journée, et encore c'est parce que j'ai un peu l'habitude de programmer et que j'évite certains pièges courants (cela ne m'a pas empêché de me prendre la tête à plusieurs reprises quand même ;) ).

Sachez qu'un tel jeu peut être réalisé de nombreuses façons différentes. Je vais vous montrer ma façon de faire : ce n'est pas la meilleure, mais ce n'est pas la plus mauvaise non plus.
Le TP se terminera par une série de suggestions d'améliorations et je vous proposerai de télécharger le code source complet bien entendu.

Encore une fois : je vous conseille d'essayer de vous y lancer par vous-mêmes. Passez-y deux ou trois jours et faites de votre mieux. Il est important que vous pratiquiez.

Le cahier des charges

Le cahier des charges est un document dans lequel on écrit tout ce que le programme doit savoir faire.
En l'occurence, que veut-on que notre jeu soit capable de faire ? C'est le moment de se décider !

Voici ce que je propose :

  • le joueur doit pouvoir se déplacer dans un labyrinthe et pousser des caisses ;

  • il ne peut pas pousser deux caisses à la fois ;

  • une partie est considérée comme gagnée lorsque toutes les caisses sont sur des objectifs ;

  • les niveaux de jeu seront enregistrés dans un fichier (par exempleniveaux.lvl) ;

  • un éditeur sera intégré au programme pour que n'importe qui puisse créer ses propres niveaux (ce n'est pas indispensable mais ça ajoutera du piment !).

Voilà qui nous donnera bien assez de travail.

À noter qu'il y a des choses que notre programme ne saura pas faire, ça aussi il faut le dire.

  • Notre programme ne pourra gérer qu'un seul niveau à la fois. Si vous voulez coder une « aventure » avec une suite de niveaux, vous n'aurez qu'à le faire vous-mêmes à la fin de ce TP.

  • Il n'y aura pas de gestion du temps écoulé (on ne sait pas encore faire ça) ni du score.

En fait, avec tout ce qu'on veut déjà faire (notamment l'éditeur de niveaux), il y en a pour un petit moment.

Récupérer les sprites du jeu

Dans la plupart des jeux 2D, que ce soient des jeux de plate-forme ou de casse-tête comme ici, on appelle les images qui composent le jeu des sprites.
Dans notre cas, j'ai décidé qu'on créerait un Sokoban mettant en scène Mario (d'où le nom « Mario Sokoban »). Comme Mario est un personnage populaire dans le monde de la 2D, on n'aura pas trop de mal à trouver des sprites de Mario. Il faudra aussi trouver des sprites pour les murs de briques, les caisses, les objectifs, etc.

Si vous faites une recherche sur Google pour « sprites », vous trouverez de nombreuses réponses. En effet, il y a beaucoup de sites qui proposent de télécharger des sprites de jeux 2D auxquels vous avez sûrement joué par le passé.

Voici les sprites que nous allons utiliser :

Sprite

Description

Image utilisateurImage utilisateurImage utilisateurImage utilisateur

Un mur

Image utilisateurImage utilisateurImage utilisateurImage utilisateur

Une caisse

Image utilisateurImage utilisateurImage utilisateurImage utilisateur

Une caisse placée sur un objectif.

Image utilisateurImage utilisateurImage utilisateurImage utilisateur

Un objectif (où l'on doit mettre une caisse).

Image utilisateurImage utilisateurImage utilisateurImage utilisateur

Le joueur (Mario) orienté vers le bas

Image utilisateurImage utilisateurImage utilisateurImage utilisateur

Mario vers la droite

Image utilisateurImage utilisateurImage utilisateurImage utilisateur

Mario vers la gauche

Image utilisateurImage utilisateurImage utilisateurImage utilisateur

Mario vers le haut

Le plus simple pour vous sera de télécharger un pack que j'ai préparé contenant toutes ces images.

Télécharger toutes les images

J'ai aussi créé une petite image qui pourra servir de menu d'accueil au lancement du programme (fig. suivante), vous la trouverez dans le pack que vous venez normalement de télécharger.

L'écran d'accueil du programme

Vous noterez que les images sont dans différents formats. Il y a des GIF, des PNG et des JPEG. Nous allons donc avoir besoin de la bibliothèqueSDL_Image.
Pensez à configurer votre projet pour qu'il gère la SDL etSDL_Image. Si vous avez oublié comment faire, revoyez les chapitres précédents. Si vous ne configurez pas votre projet correctement, on vous dira que les fonctions que vous utilisez (commeIMG_Load) n'existent pas !

Le main et les constantes

Chaque fois qu'on commence un projet assez important, il est nécessaire de bien s'organiser dès le départ.
En général, je commence par me créer un fichier de constantesconstantes.hainsi qu'un fichiermain.cqui contiendra la fonctionmain(et uniquement celle-là). Ce n'est pas une règle : c'est juste ma façon de fonctionner. Chacun a sa propre manière de faire.

Les différents fichiers du projet

Je propose de créer dès à présent tous les fichiers du projet (même s'ils restent vides au départ).
Voici donc les fichiers que je crée :

  • constantes.h: les définitions de constantes globales à tout le programme ;

  • main.c: le fichier qui contient la fonctionmain(fonction principale du programme) ;

  • jeu.c: fonctions gérant une partie de Sokoban ;

  • jeu.h: prototypes des fonctions dejeu.c;

  • editeur.c: fonctions gérant l'éditeur de niveaux ;

  • editeur.h: prototypes des fonctions deediteur.c;

  • fichiers.c: fonctions gérant la lecture et l'écriture de fichiers de niveaux (commeniveaux.lvlpar exemple) ;

  • fichiers.h: prototypes des fonctions defichiers.c.

On va commencer par créer le fichier des constantes.

Les constantes :constantes.h

Voici le contenu de mon fichier de constantes :

/*
constantes.h
------------

Par mateo21, pour Le Site du Zéro (www.siteduzero.com)

Rôle : définit des constantes pour tout le programme (taille de la fenêtre...)
*/

#ifndef DEF_CONSTANTES
#define DEF_CONSTANTES

    #define TAILLE_BLOC         34 // Taille d'un bloc (carré) en pixels
    #define NB_BLOCS_LARGEUR    12
    #define NB_BLOCS_HAUTEUR    12
    #define LARGEUR_FENETRE     TAILLE_BLOC * NB_BLOCS_LARGEUR
    #define HAUTEUR_FENETRE     TAILLE_BLOC * NB_BLOCS_HAUTEUR

    enum {HAUT, BAS, GAUCHE, DROITE};
    enum {VIDE, MUR, CAISSE, OBJECTIF, MARIO, CAISSE_OK};

#endif

Vous noterez plusieurs points intéressants dans ce petit fichier.

  • Le fichier commence par un commentaire d'en-tête. Je recommande de mettre ce type de commentaire au début de chacun de vos fichiers (que ce soient des.hou des.c). Généralement, un commentaire d'en-tête contient :

    • le nom du fichier ;

    • l'auteur ;

    • le rôle du fichier (ce à quoi servent les fonctions qu'il contient) ;

    • je ne l'ai pas fait là, mais généralement, on met aussi la date de création et la date de dernière modification. Ça permet de s'y retrouver, surtout dans les gros projets à plusieurs.

  • Le fichier est protégé contre les inclusions infinies. Il utilise la technique que l'on a apprise à la fin du chapitre sur le préprocesseur. Ici cette protection ne sert pas vraiment, mais j'ai pris l'habitude de faire ça pour chacun de mes fichiers.hsans exception.

  • Enfin, le cœur du fichier. Vous avez une série dedefine. J'indique la taille d'un petit bloc en pixels (tous mes sprites sont des carrés de 34 px). J'indique que ma fenêtre comportera 12 x 12 blocs de largeur. Je calcule comme ça les dimensions de la fenêtre par une simple multiplication des constantes. Ce que je fais là n'est pas obligatoire, mais a un énorme avantage : si plus tard je veux changer la taille du jeu, je n'aurai qu'à éditer ce fichier (et à recompiler) et tout mon code source s'adaptera en conséquence.

  • Enfin, j'ai défini d'autres constantes via des énumérations anonymes. C'est légèrement différent de ce qu'on a appris dans le chapitre sur les types de variables personnalisés. Ici, je ne crée pas un type personnalisé, je définis juste des constantes. Cela ressemble auxdefineà une différence près : c'est l'ordinateur qui attribue automatiquement un nombre à chacune des valeurs (en commençant par 0). Ainsi, on aHAUT= 0,BAS= 1,GAUCHE= 2, etc. Cela permettra de rendre notre code source beaucoup plus clair par la suite, vous verrez !

En résumé, j'utilise :

  • desdefinelorsque je veux attribuer une valeur précise à une constante (par exemple « 34 pixels ») ;

  • des énumérations lorsque la valeur attribuée à une constante ne m'importe pas. Ici, je me moque bien de savoir queHAUTvaut 0 (ça pourrait aussi bien valoir 150, ça ne changerait rien) ; tout ce qui compte pour moi, c'est que cette constante ait une valeur différente deBAS,GAUCHEetDROITE.

Inclure les définitions de constantes

Le principe sera d'inclure ce fichier de constantes dans chacun de mes fichiers.c.
Ainsi, partout dans mon code je pourrai utiliser les constantes que je viens de définir.

Il faudra donc taper la ligne suivante au début de chacun des fichiers.c:

#include "constantes.h"

Lemain:main.c

La fonctionmainprincipale est extrêmement simple. Elle a pour rôle d'afficher l'écran d'accueil du jeu et de rediriger vers la bonne section.

/*
main.c
------

Par mateo21, pour Le Site du Zéro (www.siteduzero.com)

Rôle : menu du jeu. Permet de choisir entre l'éditeur et le jeu lui-même.
*/

#include <stdlib.h>
#include <stdio.h>
#include <SDL/SDL.h>
#include <SDL/SDL_image.h>

#include "constantes.h"
#include "jeu.h"
#include "editeur.h"

int main(int argc, char *argv[])
{
    SDL_Surface *ecran = NULL, *menu = NULL;
    SDL_Rect positionMenu;
    SDL_Event event;

    int continuer = 1;

    SDL_Init(SDL_INIT_VIDEO);

    SDL_WM_SetIcon(IMG_Load("caisse.jpg"), NULL); // L'icône doit être chargée avant SDL_SetVideoMode
    ecran = SDL_SetVideoMode(LARGEUR_FENETRE, HAUTEUR_FENETRE, 32, SDL_HWSURFACE | SDL_DOUBLEBUF);
    SDL_WM_SetCaption("Mario Sokoban", NULL);

    menu = IMG_Load("menu.jpg");
    positionMenu.x = 0;
    positionMenu.y = 0;

    while (continuer)
    {
        SDL_WaitEvent(&event);
        switch(event.type)
        {
            case SDL_QUIT:
                continuer = 0;
                break;
            case SDL_KEYDOWN:
                switch(event.key.keysym.sym)
                {
                    case SDLK_ESCAPE: // Veut arrêter le jeu
                        continuer = 0;
                        break;
                    case SDLK_KP1: // Demande à jouer
                        jouer(ecran);
                        break;
                    case SDLK_KP2: // Demande l'éditeur de niveaux
                        editeur(ecran);
                        break;
                }
                break;
        }

        // Effacement de l'écran
        SDL_FillRect(ecran, NULL, SDL_MapRGB(ecran->format, 0, 0, 0));
        SDL_BlitSurface(menu, NULL, ecran, &positionMenu);
        SDL_Flip(ecran);
    }

    SDL_FreeSurface(menu);
    SDL_Quit();

    return EXIT_SUCCESS;
}

La fonctionmainse charge d'effectuer les initialisations de la SDL, de donner un titre à la fenêtre ainsi qu'une icône. À la fin de la fonction,SDL_Quit()est appelée pour arrêter la SDL proprement.

La fonction affiche un menu. Le menu est chargé en utilisant la fonctionIMG_LoaddeSDL_Image:

menu = IMG_Load("menu.jpg");
La boucle des événements

La boucle infinie gère les événements suivants :

  • arrêt du programme (SDL_QUIT) : si on demande à fermer le programme (clic sur la croix en haut à droite de la fenêtre), alors on passe le booléencontinuerà 0, et la boucle s'arrêtera. Bref, classique ;

  • appui sur la touche Echap : arrêt du programme (commeSDL_QUIT) ;

  • appui sur la touche 1 du pavé numérique : lancement du jeu (appel de la fonctionjouer) ;

  • appui sur la touche 2 du pavé numérique : lancement de l'éditeur (appel de la fonctionediteur).

Comme vous le voyez, c'est vraiment très simple. Si on appuie sur 1, le jeu est lancé. Une fois que le jeu est terminé, la fonctionjouers'arrête et on retourne dans lemaindans lequel on refait un tour de boucle. Lemainboucle à l'infini tant qu'on ne demande pas à arrêter le jeu.

Grâce à cette petite organisation très simple, on peut donc gérer le menu dans lemainet laisser des fonctions spéciales (commejouer, ouediteur) gérer les différentes parties du programme.

Le jeu

Attaquons maintenant le gros du sujet : la fonctionjouer!
Cette fonction est la plus importante du programme, aussi soyez attentifs car c'est vraiment là qu'il faut comprendre. Vous verrez après que créer l'éditeur de niveaux n'est pas si compliqué que ça en a l'air.

Les paramètres envoyés à la fonction

La fonctionjouera besoin d'un paramètre : la surfaceecran. En effet, la fenêtre a été ouverte dans lemain, et pour que la fonctionjouerpuisse y dessiner, il faut qu'elle récupère le pointeur surecran!

Si vous regardez lemainà nouveau, vous voyez qu'on appellejoueren lui envoyantecran:

jouer(ecran);

Le prototype de la fonction, que vous pouvez mettre dansjeu.h, est donc le suivant :

void jouer(SDL_Surface* ecran);

Les déclarations de variables

Cette fonction va avoir besoin de nombreuses variables.
Je n'ai pas pensé à toutes les variables dont j'ai eu besoin du premier coup. Il y en a donc certaines que j'ai ajoutées par la suite.

Variables de types définis par la SDL

Voici pour commencer toutes les variables de types définis par la SDL dont j'ai besoin :

SDL_Surface *mario[4] = {NULL}; // 4 surfaces pour 4 directions de mario
SDL_Surface *mur = NULL, *caisse = NULL, *caisseOK = NULL, *objectif = NULL, *marioActuel = NULL;
SDL_Rect position, positionJoueur;
SDL_Event event;

J'ai créé un tableau deSDL_Surfaceappelémario. C'est un tableau de quatre cases qui stockera Mario dans chacune des directions (un vers le bas, un autre vers la gauche, vers le haut et vers la droite).

Il y a ensuite plusieurs surfaces correspondant à chacun des sprites que je vous ai fait télécharger plus haut :mur,caisse,caisseOK(une caisse sur un objectif) etobjectif.

À quoi sertmarioActuel?

C'est un pointeur vers une surface. Il pointe sur la surface correspondant au Mario orienté dans la direction actuelle. C'est doncmarioActuelque l'on blittera à l'écran. Si vous regardez tout en bas de la fonctionjouer, vous verrez justement :

SDL_BlitSurface(marioActuel, NULL, ecran, &position);

On ne blitte donc pas un élément du tableaumario, mais le pointeurmarioActuel.
Ainsi, en blittantmarioActuel, on blitte soit le Mario vers le bas, soit celui vers le haut, etc. Le pointeurmarioActuelpointe vers une des cases du tableaumario.

Quoi d'autre à part ça ?
Une variablepositionde typeSDL_Rectdont on se servira pour définir la position des éléments à blitter (on s'en servira pour tous les sprites, inutile de créer unSDL_Rectpour chaque surface !).positionJoueurest en revanche un peu différente : elle indique à quelle case sur la carte se trouve actuellement le joueur. Enfin, la variableeventtraitera les événements.

Variables plus « classiques »

J'ai aussi besoin de me créer des variables un peu plus classiques de typeint(entier).

int continuer = 1, objectifsRestants = 0, i = 0, j = 0;
int carte[NB_BLOCS_LARGEUR][NB_BLOCS_HAUTEUR] = {0};

continueretobjectifsRestantssont des booléens.
ietjsont des petites variables qui vont me permettre de parcourir le tableaucarte.

C'est là que les choses deviennent vraiment intéressantes. J'ai en effet créé un tableau à deux dimensions. Je ne vous ai pas parlé de ce type de tableaux auparavant, mais c'est justement le moment idéal pour vous apprendre ce que c'est. Ce n'est pas bien compliqué, vous allez voir.

Regardez la définition de plus près :

int carte[NB_BLOCS_LARGEUR][NB_BLOCS_HAUTEUR] = {0};

En fait, il s'agit d'un tableau d'int(entiers) qui a la particularité d'avoir deux paires de crochets[ ].
Si vous vous souvenez bien deconstantes.h,NB_BLOCS_LARGEURetNB_BLOCS_HAUTEURsont des constantes qui valent toutes les deux 12.

Ce tableau sera donc à la compilation créé comme ceci :

int carte[12][12] = {0};

Mais qu'est-ce que ça veut dire ?

Ça veut dire que pour chaque « case » decarte, il y a 12 sous-cases.
Il y aura donc les variables suivantes :

carte[0][0]
carte[0][1]
carte[0][2]
carte[0][3]
carte[0][4]
carte[0][5]
carte[0][6]
carte[0][7]
carte[0][8]
carte[0][9]
carte[0][10]
carte[0][11]
carte[1][0]
carte[1][1]
carte[1][2]
carte[1][3]
carte[1][4]
carte[1][5]
carte[1][6]
carte[1][7]
carte[1][8]
carte[1][9]
carte[1][10]
...
carte[11][2]
carte[11][3]
carte[11][4]
carte[11][5]
carte[11][6]
carte[11][7]
carte[11][8]
carte[11][9]
carte[11][10]
carte[11][11]

C'est donc un tableau de 12 * 12 = 144 cases !
Chacune des ces cases représente une case de la carte.

La fig. suivante vous donne une idée de la façon dont la carte est représentée.

Découpage de la carte

Ainsi, la case en haut à gauche est stockée danscarte[0][0].
La case en haut à droite est stockée danscarte[0][11].
La case en bas à droite (la toute dernière) est stockée danscarte[11][11].

Selon la valeur de la case (qui est un nombre entier), on sait si la case contient un mur, une caisse, un objectif, etc.). C'est justement là que va servir notre énumération de tout à l'heure !

enum {VIDE, MUR, CAISSE, OBJECTIF, MARIO, CAISSE_OK};

Si la case vautVIDE(0) on sait que cette partie de l'écran devra rester blanche. Si elle vautMUR(1), on sait qu'il faudra blitter une image de mur, etc.

Initialisations

Chargement des surfaces

Maintenant qu'on a passé en revue toutes les variables de la fonctionjouer, on peut commencer à faire quelques initialisations :

// Chargement des sprites (décors, personnage...)
mur = IMG_Load("mur.jpg");
caisse = IMG_Load("caisse.jpg");
caisseOK = IMG_Load("caisse_ok.jpg");
objectif = IMG_Load("objectif.png");
mario[BAS] = IMG_Load("mario_bas.gif");
mario[GAUCHE] = IMG_Load("mario_gauche.gif");
mario[HAUT] = IMG_Load("mario_haut.gif");
mario[DROITE] = IMG_Load("mario_droite.gif");

Rien de sorcier là-dedans : on charge tout grâce àIMG_Load.
S'il y a une petite particularité, c'est le chargement demario. On charge en effet Mario dans chacune des directions dans le tableaumarioen utilisant les constantesHAUT,BAS,GAUCHE,DROITE. Le fait d'utiliser les constantes rend ici — comme vous le voyez — le code plus clair. On aurait très bien pu écriremario[0], mais c'est quand même plus lisible d'avoirmario[HAUT]par exemple !

Orientation initiale du Mario (marioActuel)

On initialise ensuitemarioActuelpour qu'il ait une direction au départ :

marioActuel = mario[BAS]; // Mario sera dirigé vers le bas au départ

J'ai trouvé plus logique de commencer la partie avec un Mario qui regarde vers le bas (c'est-à-dire vers nous). Si vous voulez, vous pouvez changer cette ligne et mettre :

marioActuel = mario[DROITE];

Vous verrez que Mario sera alors orienté vers la droite au début du jeu.

Chargement de la carte

Maintenant, il va falloir remplir notre tableau à deux dimensionscarte. Pour l'instant, ce tableau ne contient que des 0.
Il faut lire le niveau qui est stocké dans le fichierniveaux.lvl.

// Chargement du niveau
if (!chargerNiveau(carte))
    exit(EXIT_FAILURE); // On arrête le jeu si on n'a pas pu charger le niveau

J'ai choisi de faire gérer le chargement (et l'enregistrement) de niveaux par des fonctions situées dansfichiers.c.
Ici, on appelle donc la fonctionchargerNiveau. On l'étudiera plus en détails plus loin (elle n'est pas très compliquée, de toute manière). Tout ce qui nous intéresse ici c'est de savoir que notre niveau a été chargé dans le tableaucarte.

On teste donc le résultat du chargement dans une condition. Si le résultat est négatif (d'où le point d'exclamation qui sert à exprimer la négation), on arrête tout : on appelleexit.
Sinon, c'est que tout va bien et on peut continuer.

Nous possédons maintenant un tableaucartequi décrit le contenu de chaque case :MUR,VIDE,CAISSE

Recherche de la position de départ de Mario

Il faut maintenant initialiser la variablepositionJoueur.
Cette variable, de typeSDL_Rect, est un peu particulière. On ne s'en sert pas pour stocker des coordonnées en pixels. On s'en sert pour stocker des coordonnées en « cases » sur la carte. Ainsi, si on a :

positionJoueur.x == 11 positionJoueur.y == 11

… c'est que le joueur se trouve dans la toute dernière case en bas à droite de la carte.
Reportez-vous au schéma de la carte de la fig. suivante pour bien voir à quoi ça correspond si vous avez (déjà) oublié.

On doit parcourir notre tableaucarteà deux dimensions à l'aide d'une double boucle. On utilise la petite variableipour parcourir le tableau verticalement et la variablejpour le parcourir horizontalement :

// Recherche de la position de Mario au départ
for (i = 0 ; i < NB_BLOCS_LARGEUR ; i++)
{
    for (j = 0 ; j < NB_BLOCS_HAUTEUR ; j++)
    {
        if (carte[i][j] == MARIO) // Si Mario se trouve à cette position
        {
            positionJoueur.x = i;
            positionJoueur.y = j;
            carte[i][j] = VIDE;
        }
    }
}

À chaque case, on teste si elle contientMARIO(c'est-à-dire le départ du joueur sur la carte). Si c'est le cas, on stocke les coordonnées actuelles (situées dansietj) dans la variablepositionJoueur.
On efface aussi la case en la mettant àVIDEpour qu'elle soit considérée comme une case vide par la suite.

Activation de la répétition des touches

Dernière chose, très simple : on active la répétition des touches pour qu'on puisse se déplacer sur la carte en laissant une touche enfoncée.

// Activation de la répétition des touches
SDL_EnableKeyRepeat(100, 100);

La boucle principale

Pfiou ! Nos initialisations sont faites, on peut maintenant s'occuper de la boucle principale.

C'est une boucle classique qui fonctionne sur le même schéma que celles qu'on a vues jusqu'ici. Elle est juste un peu plus grosse et un peu plus complète (faut c'qui faut comme on dit !).

Regardons de plus près leswitchqui teste l'événement :

switch(event.type)
{
    case SDL_QUIT:
        continuer = 0;
        break;
    case SDL_KEYDOWN:
        switch(event.key.keysym.sym)
        {
            case SDLK_ESCAPE:
                continuer = 0;
                break;
            case SDLK_UP:
                marioActuel = mario[HAUT];
                deplacerJoueur(carte, &positionJoueur, HAUT);
                break;
            case SDLK_DOWN:
                marioActuel = mario[BAS];
                deplacerJoueur(carte, &positionJoueur, BAS);
                break;
            case SDLK_RIGHT:
                marioActuel = mario[DROITE];
                deplacerJoueur(carte, &positionJoueur, DROITE);
                break;
            case SDLK_LEFT:
                marioActuel = mario[GAUCHE];
                deplacerJoueur(carte, &positionJoueur, GAUCHE);
                break;
        }
        break;
}

Si on appuie sur la toucheEchap, le jeu s'arrêtera et on retournera au menu principal.

Comme vous le voyez, il n'y a pas 36 événements différents à gérer : on teste juste si le joueur appuie sur les touches « haut », « bas », « gauche » ou « droite » de son clavier.
Selon la touche enfoncée, on change la direction de Mario. C'est là qu'intervientmarioActuel! Si on appuie vers le haut, alors :

marioActuel = mario[HAUT];

Si on appuie vers le bas, alors :

marioActuel = mario[BAS];

marioActuelpointe donc sur la surface représentant Mario dans la position actuelle. C'est ainsi qu'en blittantmarioActueltout à l'heure, on sera certain de blitter Mario dans la bonne direction.

Maintenant, chose très importante : on appelle une fonctiondeplacerJoueur. Cette fonction va déplacer le joueur sur la carte s'il a le droit de le faire.

  • Par exemple, on ne peut pas faire monter Mario d'un cran vers le haut s'il se trouve déjà tout en haut de la carte.

  • On ne peut pas non plus le faire monter s'il y a un mur au-dessus de lui.

  • On ne peut pas le faire monter s'il y a deux caisses au-dessus de lui.

  • Par contre, on peut le faire monter s'il y a juste une caisse au-dessus de lui.

  • Mais attention, on ne peut pas le faire monter s'il y a une caisse au-dessus de lui et que la caisse se trouve au bord de la carte !

Oh la la, c'est quoi ce bazar ?

C'est ce qu'on appelle la gestion des collisions. Si ça peut vous rassurer, ici c'est une gestion des collisions extrêmement simple, vu que le joueur se déplace par « cases » et dans seulement quatre directions possibles à la fois. Dans un jeu 2D où on peut se déplacer dans toutes les directions pixel par pixel, la gestion des collisions est bien plus complexe.

Mais il y a pire : la 3D. La gestion des collisions dans un jeu 3D est vraiment la bête noire des programmeurs. Heureusement, il existe des bibliothèques de gestion des collisions en 3D qui font le gros du travail à notre place.

Revenons à la fonctiondeplacerJoueuret concentrons-nous. On lui envoie trois paramètres :

  • la carte : pour qu'elle puisse la lire mais aussi la modifier, si on déplace une caisse par exemple ;

  • la position du joueur : là aussi, la fonction devra lire et éventuellement modifier la position du joueur ;

  • la direction dans laquelle on demande à aller : on utilise là encore les constantesHAUT,BAS,GAUCHE,DROITEpour plus de lisibilité.

Nous étudierons la fonctiondeplacerJoueurplus loin. J'aurais très bien pu mettre tous les tests dans leswitch, mais celui-ci serait devenu énorme et illisible. C'est là que découper son programme en fonctions prend tout son intérêt.

Blittons, blittons, la queue du cochon

Notreswitchest terminé : à ce stade du programme, la carte et le joueur ont probablement changé. Quoi qu'il en soit, c'est l'heure du blit !

On commence par effacer l'écran en lui donnant une couleur de fond blanche :

// Effacement de l'écran
SDL_FillRect(ecran, NULL, SDL_MapRGB(ecran->format, 255, 255, 255));

Et maintenant, on parcourt tout notre tableau à deux dimensionscartepour savoir quel élément blitter à quel endroit sur l'écran.
On effectue une double boucle comme on l'a vu plus tôt pour parcourir toutes les 144 cases du tableau :

// Placement des objets à l'écran
objectifsRestants = 0;

for (i = 0 ; i < NB_BLOCS_LARGEUR ; i++)
{
    for (j = 0 ; j < NB_BLOCS_HAUTEUR ; j++)
    {
        position.x = i * TAILLE_BLOC;
        position.y = j * TAILLE_BLOC;

        switch(carte[i][j])
        {
            case MUR:
                SDL_BlitSurface(mur, NULL, ecran, &position);
                break;
            case CAISSE:
                SDL_BlitSurface(caisse, NULL, ecran, &position);
                break;
            case CAISSE_OK:
                SDL_BlitSurface(caisseOK, NULL, ecran, &position);
                break;
            case OBJECTIF:
                SDL_BlitSurface(objectif, NULL, ecran, &position);
                objectifsRestants = 1;
                break;
        }
    }
}

Pour chacune des cases, on prépare la variableposition(de typeSDL_Rect) pour placer l'élément actuel à la bonne position sur l'écran.
Le calcul est très simple :

position.x = i * TAILLE_BLOC;
position.y = j * TAILLE_BLOC;

Il suffit de multiplieriparTAILLE_BLOCpour avoirposition.x.
Ainsi, si on se trouve à la troisième case, c'est queivaut 2 (n'oubliez pas queicommence à 0 !). On fait donc le calcul 2 * 34 = 68. On blittera donc l'image 68 pixels vers la droite surecran.
On fait la même chose pour les ordonnéesy.

Ensuite, on fait unswitchsur la case de la carte qu'on est en train d'analyser.
Là encore, avoir défini des constantes est vraiment pratique et rend les choses plus lisibles.
On teste donc si la case vautMUR, dans ce cas on blitte un mur. De même pour les caisses et les objectifs.

Test de victoire

Vous remarquerez qu'avant la double boucle, on initialise le booléenobjectifsRestantsà 0.
Ce booléen sera mis à 1 dès qu'on aura détecté un objectif sur la carte. S'il ne reste plus d'objectifs, c'est que toutes les caisses sont sur des objectifs (il n'y a plus que desCAISSE_OK).

Il suffit de tester si le booléen vaut « faux », c'est-à-dire s'il ne reste plus d'objectifs.
Dans ce cas, on met la variablecontinuerà 0 pour arrêter la partie :

// Si on n'a trouvé aucun objectif sur la carte, c'est qu'on a gagné
if (!objectifsRestants)
    continuer = 0;
Le joueur

Il nous reste à blitter le joueur :

// On place le joueur à la bonne position
position.x = positionJoueur.x * TAILLE_BLOC;
position.y = positionJoueur.y * TAILLE_BLOC;
SDL_BlitSurface(marioActuel, NULL, ecran, &position);

On calcule sa position (en pixels cette fois) en faisant une simple multiplication entrepositionJoueuretTAILLE_BLOC. On blitte ensuite le joueur à la position indiquée.

Flip !

On a tout fait, il ne nous reste plus qu'à afficher l'écran au joueur :

SDL_Flip(ecran);

Fin de la fonction : déchargements

Après la boucle principale, on doit faire quelquesFreeSurfacepour libérer la mémoire des sprites qu'on a chargés.
On désactive aussi la répétition des touches en envoyant les valeurs 0 à la fonctionSDL_EnableKeyRepeat:

// Désactivation de la répétition des touches (remise à 0)
SDL_EnableKeyRepeat(0, 0);

// Libération des surfaces chargées
SDL_FreeSurface(mur);
SDL_FreeSurface(caisse);
SDL_FreeSurface(caisseOK);
SDL_FreeSurface(objectif);
for (i = 0 ; i < 4 ; i++)
    SDL_FreeSurface(mario[i]);

La fonctiondeplacerJoueur

La fonctiondeplacerJoueurse trouve elle aussi dansjeu.c.
C'est une fonction… assez délicate à écrire. C'est peut-être la principale difficulté que l'on rencontre lorsqu'on code un jeu de Sokoban.

Rappel : la fonctiondeplacerJoueurvérifie si on a le droit de déplacer le joueur dans la direction demandée. Elle met à jour la position du joueur (positionJoueur) et aussi la carte si une caisse a été déplacée.

Voici le prototype de la fonction :

void deplacerJoueur(int carte[][NB_BLOCS_HAUTEUR], SDL_Rect *pos, int direction);

Ce prototype est un peu particulier. Vous voyez que j'envoie le tableaucarteet que je précise la taille de la deuxième dimension (NB_BLOCS_HAUTEUR).
Pourquoi cela ?

La réponse est un peu compliquée pour que je la développe au milieu de ce cours. Pour faire simple, le C ne devine pas qu'il s'agit d'un tableau à deux dimensions et il faut au moins donner la taille de la seconde dimension pour que ça fonctionne.
Donc, lorsque vous envoyez un tableau à deux dimensions à une fonction, vous devez indiquer la taille de la seconde dimension dans le prototype. C'est comme ça, c'est obligatoire.

Autre chose : vous noterez quepositionJoueurs'appelle en faitposdans cette fonction. J'ai choisi de raccourcir le nom parce que c'est plus court à écrire, et vu qu'on va avoir besoin de l'écrire de nombreuses fois, autant ne pas se fatiguer.

Commençons par tester la direction dans laquelle on veut aller via un grandswitch:

switch(direction)
{
    case HAUT:
    /* etc. */
Et c'est parti pour des tests de folie !

Il faut maintenant écrire tous les tests de tous les cas possibles, en essayant de ne pas en oublier un seul.

Voici comment je procède : je teste toutes les possibilités de collision cas par cas, et dès que je détecte une collision (qui fait que le joueur ne peut pas bouger), je fais unbreak;pour sortir duswitch, et donc empêcher le déplacement.

Voici par exemple toutes les possibilités de collision qui existent pour un joueur qui veut se déplacer vers le haut :

  • le joueur est déjà tout en haut de la carte ;

  • il y a un mur au-dessus du joueur ;

  • il y a deux caisses au-dessus du joueur (et il ne peut pas déplacer deux caisses à la fois, rappelez-vous) ;

  • il y a une caisse puis le bord de la carte.

Si tous ces tests sont ok, alors je me permets de déplacer le joueur.

Je vais vous montrer les tests pour un déplacement vers le haut. Pour les autres sens, il suffira d'adapter un petit peu le code.

if (pos->y - 1 < 0) // Si le joueur dépasse l'écran, on arrête
    break;

On commence par vérifier si le joueur est déjà tout en haut de l'écran. En effet, si on essayait d'appelercarte[5][-1]par exemple, ce serait le plantage du programme assuré !
On commence donc par vérifier qu'on ne va pas « déborder » de l'écran.

Ensuite :

if (carte[pos->x][pos->y - 1] == MUR) // S'il y a un mur, on arrête
    break;

Là encore c'est simple. On vérifie s'il n'y a pas un mur au-dessus du joueur. Si tel est le cas, on arrête (break).

Ensuite (attention les yeux) :

// Si on veut pousser une caisse, il faut vérifier qu'il n'y a pas de mur derrière (ou une autre caisse, ou la limite du monde)
if ((carte[pos->x][pos->y - 1] == CAISSE || carte[pos->x][pos->y - 1] == CAISSE_OK) &&
    (pos->y - 2 < 0 || carte[pos->x][pos->y - 2] == MUR ||
    carte[pos->x][pos->y - 2] == CAISSE || carte[pos->x][pos->y - 2] == CAISSE_OK))
    break;

Ce gros test peut se traduire comme ceci : « SI au-dessus du joueur il y a unecaisse(ou unecaisse_ok, c'est-à-dire une caisse bien placée)
ET SI au-dessus de cette caisse il y a soit le vide (on déborde du niveau car on est tout en haut), soit une autre caisse, soit une caisse_ok :
ALORS on ne peut pas se déplacer :break. »

Si on arrive à passer ce test, on a le droit de déplacer le joueur. Ouf !
On appelle d'abord une fonction qui va déplacer une caisse si nécessaire :

// Si on arrive là, c'est qu'on peut déplacer le joueur !
// On vérifie d'abord s'il y a une caisse à déplacer
deplacerCaisse(&carte[pos->x][pos->y - 1], &carte[pos->x][pos->y - 2]);
Le déplacement de caisse :deplacerCaisse

J'ai choisi de gérer le déplacement de caisse dans une autre fonction car c'est le même code pour les quatre directions. On doit juste s'être assuré avant qu'on a le droit de se déplacer (ce qu'on vient de faire).
On envoie à la fonction deux paramètres : le contenu de la case dans laquelle on veut aller et le contenu de la case d'après.

void deplacerCaisse(int *premiereCase, int *secondeCase)
{
    if (*premiereCase == CAISSE || *premiereCase == CAISSE_OK)
    {
        if (*secondeCase == OBJECTIF)
            *secondeCase = CAISSE_OK;
        else
            *secondeCase = CAISSE;

        if (*premiereCase == CAISSE_OK)
            *premiereCase = OBJECTIF;
        else
            *premiereCase = VIDE;
    }
}

Cette fonction met à jour la carte car elle prend en paramètres des pointeurs sur les cases concernées.
Je vous laisse la lire, c'est assez simple à comprendre. Il ne faut pas oublier que si on déplace uneCAISSE_OK, il faut remplacer la case où elle se trouvait par unOBJECTIF. Sinon, si c'est une simpleCAISSE, alors on remplace la case en question par duVIDE.

Déplacer le joueur

On retourne dans la fonctiondeplacerJoueur.
Cette fois c'est la bonne, on peut déplacer le joueur.

Comment fait-on ? C'est très très simple :

pos->y--; // On peut enfin faire monter le joueur (oufff !)

Il suffit de diminuer l'ordonnée y (car le joueur veut monter).

Résumé

En guise de résumé, voici tous les tests pour le cas HAUT :

switch(direction)
{
    case HAUT:
        if (pos->y - 1 < 0) // Si le joueur dépasse l'écran, on arrête
            break;
        if (carte[pos->x][pos->y - 1] == MUR) // S'il y a un mur, on arrête
            break;
        // Si on veut pousser une caisse, il faut vérifier qu'il n'y a pas de mur derrière (ou une autre caisse, ou la limite du monde)
        if ((carte[pos->x][pos->y - 1] == CAISSE || carte[pos->x][pos->y - 1] == CAISSE_OK) &&
            (pos->y - 2 < 0 || carte[pos->x][pos->y - 2] == MUR ||
            carte[pos->x][pos->y - 2] == CAISSE || carte[pos->x][pos->y - 2] == CAISSE_OK))
            break;
        
        // Si on arrive là, c'est qu'on peut déplacer le joueur !
        // On vérifie d'abord s'il y a une caisse à déplacer
        deplacerCaisse(&carte[pos->x][pos->y - 1], &carte[pos->x][pos->y - 2]);
        
        pos->y--; // On peut enfin faire monter le joueur (oufff !)
        break;

Je vous laisse le soin de faire du copier-coller pour les autres cas (attention, il faudra adapter le code, ce n'est pas exactement pareil à chaque fois !).

Et voilà, on vient de finir de coder le jeu !
Enfin presque : il nous reste à voir la fonction de chargement (et de sauvegarde) de niveaux.
On verra ensuite comment créer l'éditeur. Rassurez-vous, ça ira bien plus vite !

Chargement et enregistrement de niveaux

Le fichierfichiers.ccontient deux fonctions :

  • chargerNiveau;

  • sauvegarderNiveau.

Commençons par le chargement de niveau.

chargerNiveau

Cette fonction prend un paramètre : la carte. Là encore, il faut préciser la taille de la seconde dimension car il s'agit d'un tableau à deux dimensions.
La fonction renvoie un booléen : « vrai » si le chargement a réussi, « faux » si c'est un échec.

Le prototype est donc :

int chargerNiveau(int niveau[][NB_BLOCS_HAUTEUR]);

Voyons le début de la fonction :

FILE* fichier = NULL;
char ligneFichier[NB_BLOCS_LARGEUR * NB_BLOCS_HAUTEUR + 1] = {0};
int i = 0, j = 0;

fichier = fopen("niveaux.lvl", "r");
if (fichier == NULL)
    return 0;

On crée un tableau decharpour stocker temporairement le résultat du chargement du niveau.
On ouvre le fichier en lecture seule ("r"). On arrête la fonction en renvoyant 0 (« faux ») si l'ouverture a échoué. Classique.

Le fichierniveaux.lvlcontient une ligne qui est une suite de nombres. Chaque nombre représente une case du niveau. Par exemple :

11111001111111111400000111110001100103310101101100000200121110 [...]

On va donc lire cette ligne avec unfgets:

fgets(ligneFichier, NB_BLOCS_LARGEUR * NB_BLOCS_HAUTEUR + 1, fichier);

On va analyser le contenu deligneFichier. On sait que les 12 premiers caractères représentent la première ligne, les 12 suivants la seconde ligne, etc.

for (i = 0 ; i < NB_BLOCS_LARGEUR ; i++)
{
    for (j = 0 ; j < NB_BLOCS_HAUTEUR ; j++)
    {
        switch (ligneFichier[(i * NB_BLOCS_LARGEUR) + j])
        {
            case '0':
                niveau[j][i] = 0;
                break;
            case '1':
                niveau[j][i] = 1;
                break;
            case '2':
                niveau[j][i] = 2;
                break;
            case '3':
                niveau[j][i] = 3;
                break;
            case '4':
                niveau[j][i] = 4;
                break;
        }
    }
}

Par un simple petit calcul, on prend le caractère qui nous intéresse dansligneFichieret on analyse sa valeur.

Leswitchfait la conversion'0'=> 0,'1'=> 1, etc. Il place tout dans le tableaucarte. La carte s'appelleniveaudans cette fonction d'ailleurs, mais ça ne change rien.

Une fois que c'est fait, on peut fermer le fichier et renvoyer 1 pour dire que tout s'est bien passé :

fclose(fichier);
return 1;

Finalement, le chargement du niveau dans le fichier n'était pas bien compliqué. Le seul piège à éviter c'était de bien penser à convertir la valeur ASCII'0'en un nombre 0 (et de même pour 1, 2, 3, 4…).

sauvegarderNiveau

Cette fonction est là encore simple :

int sauvegarderNiveau(int niveau[][NB_BLOCS_HAUTEUR])
{
    FILE* fichier = NULL;
    int i = 0, j = 0;

    fichier = fopen("niveaux.lvl", "w");
    if (fichier == NULL)
        return 0;

    for (i = 0 ; i < NB_BLOCS_LARGEUR ; i++)
    {
        for (j = 0 ; j < NB_BLOCS_HAUTEUR ; j++)
        {
            fprintf(fichier, "%d", niveau[j][i]);
        }
    }

    fclose(fichier);
    return 1;
}

J'utilisefprintfpour « traduire » les nombres du tableauniveauen caractères ASCII. C'était là encore la seule difficulté (mais à l'envers) : il ne faut pas écrire 0 mais'0'.

L'éditeur de niveaux

L'éditeur de niveaux est plus facile à créer qu'on ne pourrait l'imaginer.
En plus c'est une fonctionnalité qui va considérablement allonger la durée de vie de notre jeu, alors pourquoi s'en priver ?

Voilà comment l'éditeur va fonctionner.

  • On utilise la souris pour placer les blocs qu'on veut sur l'écran.

  • Un clic droit efface le bloc sur lequel se trouve la souris.

  • Un clic gauche place un objet. Cet objet est mémorisé : par défaut, on pose des murs avec le clic gauche. On peut changer l'objet en cours en appuyant sur les touches du pavé numérique :

    • 1 : mur,

    • 2 : caisse,

    • 3 : objectif,

    • 4 : départ du joueur Mario.

  • En appuyant sur S, le niveau sera sauvegardé.

  • On peut revenir au menu principal en appuyant surEchap.

Édition d'un niveau avec l'éditeur

Initialisations

Globalement, la fonction ressemble à celle du jeu. J'ai d'ailleurs commencé à la créer en faisant un simple copier-coller de la fonction de jeu, puis en enlevant ce qui ne servait plus et en ajoutant de nouvelles fonctionnalités.

Le début y ressemble déjà pas mal :

void editeur(SDL_Surface* ecran)
{
    SDL_Surface *mur = NULL, *caisse = NULL, *objectif = NULL, *mario = NULL;
    SDL_Rect position;
    SDL_Event event;
    
    int continuer = 1, clicGaucheEnCours = 0, clicDroitEnCours = 0;
    int objetActuel = MUR, i = 0, j = 0;
    int carte[NB_BLOCS_LARGEUR][NB_BLOCS_HAUTEUR] = {0};
    
    // Chargement des objets et du niveau
    mur = IMG_Load("mur.jpg");
    caisse = IMG_Load("caisse.jpg");
    objectif = IMG_Load("objectif.png");
    mario = IMG_Load("mario_bas.gif");
    
    if (!chargerNiveau(carte))
        exit(EXIT_FAILURE);

Là, vous avez les définitions de variables et les initialisations.
Vous remarquerez que je ne charge qu'un Mario (celui dirigé vers le bas). En effet, on ne va pas diriger Mario au clavier là, on a juste besoin d'un sprite représentant la position de départ de Mario.

La variableobjetActuelretient l'objet actuellement sélectionné par l'utilisateur. Par défaut, c'est unMUR. Le clic gauche créera donc un mur au départ, mais cela pourra être changé par l'utilisateur en appuyant sur 1, 2, 3 ou 4.

Les booléensclicGaucheEnCoursetclicDroitEnCours, comme leurs noms l'indiquent, permettent de mémoriser si un clic est en cours (si le bouton de la souris est enfoncé). Je vous expliquerai le principe un peu plus loin. Cela nous permettra de poser des objets à l'écran en laissant le bouton de la souris enfoncé. Sinon on est obligé de cliquer frénétiquement avec la souris pour placer plusieurs fois le même objet à différents endroits, ce qui est un peu fatigant.

Enfin, la carte actuellement sauvegardée dansniveaux.lvlest chargée. Ce sera notre point de départ.

La gestion des événements

Cette fois, on va devoir gérer un nombre important d'événements différents. Allons-y, un par un.

SDL_QUIT
case SDL_QUIT:
    continuer = 0;
    break;

Si on clique sur la croix, la boucle s'arrête et on revient au menu principal.
Notez que ce n'est pas ce qu'il y a de plus ergonomique pour l'utilisateur : celui-ci s'attend plutôt à ce que le programme s'arrête quand on clique sur la croix, or ce n'est pas ce qu'il se passe ici car on ne fait que revenir au menu. Il faudrait peut-être trouver un moyen d'arrêter le programme en renvoyant une valeur spéciale à la fonctionmainpar exemple. Je vous laisse réfléchir à une solution.

SDL_MOUSEBUTTONDOWN
case SDL_MOUSEBUTTONDOWN:
    if (event.button.button == SDL_BUTTON_LEFT)
    {
        // On met l'objet actuellement choisi (mur, caisse...) à l'endroit du clic
        carte[event.button.x / TAILLE_BLOC][event.button.y / TAILLE_BLOC] = objetActuel;
        clicGaucheEnCours = 1; // On retient qu'un bouton est enfoncé
    }
    else if (event.button.button == SDL_BUTTON_RIGHT) // Clic droit pour effacer
    {
        carte[event.button.x / TAILLE_BLOC][event.button.y /TAILLE_BLOC] = VIDE;
        clicDroitEnCours = 1;
    }
    break;

On commence par tester le bouton qui est enfoncé (on vérifie si c'est le clic gauche ou le clic droit) :

  • si c'est un clic gauche, on place l'objetActuelsur la carte à la position de la souris ;

  • si c'est un clic droit, on efface ce qu'il y a à cet endroit sur la carte (on metVIDEcomme je vous avais dit).

Comment sait-on sur quelle « case » de la carte on se trouve ?

On le retrouve grâce à un petit calcul. Il suffit de prendre les coordonnées de la souris (event.button.xpar exemple) et de diviser cette valeur par la taille d'un bloc (TAILLE_BLOC).
C'est une division de nombres entiers. Comme en C une division de nombres entiers donne un nombre entier, on est sûr d'avoir une valeur qui corresponde à une des cases de la carte.

Par exemple, si je suis au 75e pixel sur la carte (sur l'axe des abscisses x), je divise ce nombre parTAILLE_BLOCqui vaut ici 34.

75 / 34 = 2

N'oubliez pas que le reste est ignoré. On ne garde que la partie entière de la division en C car il s'agit d'une divison de nombres entiers.
On sait donc qu'on se trouve sur la case n° 2 (c'est-à-dire la troisième case, car un tableau commence à 0, souvenez-vous).

Autre exemple : si je suis au 10e pixel (c'est-à-dire très proche du bord), ça va donner le calcul suivant :

10 / 34 = 0

On est donc à la case n° 0 !

C'est comme ça qu'un simple petit calcul nous permet de savoir sur quelle case de la carte on se situe.

carte[event.button.x / TAILLE_BLOC][event.button.y / TAILLE_BLOC] = objetActuel;

Autre chose très importante : on met un booléenclicGaucheEnCours(ouclicDroitselon le cas) à 1. Cela nous permettra de savoir lors d'un événementMOUSEMOTIONsi un bouton de la souris est enfoncé pendant le déplacement.

SDL_MOUSEBUTTONUP
case SDL_MOUSEBUTTONUP: // On désactive le booléen qui disait qu'un bouton était enfoncé
    if (event.button.button == SDL_BUTTON_LEFT)
        clicGaucheEnCours = 0;
    else if (event.button.button == SDL_BUTTON_RIGHT)
        clicDroitEnCours = 0;
    break;

L'événementMOUSEBUTTONUPsert simplement à remettre le booléen à 0. On sait que le clic est terminé et donc qu'il n'y a plus de « clic en cours ».

SDL_MOUSEMOTION
case SDL_MOUSEMOTION:
    if (clicGaucheEnCours) // Si on déplace la souris et que le bouton gauche de la souris est enfoncé
    {
        carte[event.motion.x / TAILLE_BLOC][event.motion.y / TAILLE_BLOC] = objetActuel;
    }
    else if (clicDroitEnCours) // Pareil pour le bouton droit de la souris
    {
        carte[event.motion.x / TAILLE_BLOC][event.motion.y / TAILLE_BLOC] = VIDE;
    }
    break;

C'est là que nos booléens prennent toute leur importance. On vérifie quand on bouge la souris si un clic est en cours. Si tel est le cas, on place sur la carte un objet (ou du vide si c'est un clic droit).
Cela nous permet donc de placer d'affilée plusieurs objets du même type sans avoir à cliquer plusieurs fois. On a juste à déplacer la souris en maintenant son bouton enfoncé !

En clair : à chaque fois qu'on bouge la souris (ne serait-ce que d'un pixel), on vérifie si un des booléens est activé. Si tel est le cas, alors on pose un objet sur la carte. Sinon, on ne fait rien.

Résumé : je résume la technique, car vous vous en servirez certainement dans d'autres programmes.
Cette technique permet de savoir si un bouton de la souris est enfoncé lorsqu'on la déplace. On peut s'en servir pour coder un glisser-déplacer.

  1. Lors d'unMOUSEBUTTONDOWN: on met un booléenclicEnCoursà 1.

  2. Lors d'unMOUSEMOTION: on teste si le booléenclicEnCoursvaut « vrai ». S'il vaut « vrai », on sait qu'on est en train de faire une sorte de glisser-déplacer avec la souris.

  3. Lors d'unMOUSEBUTTONUP: on remet le booléenclicEnCoursà 0, car le clic est terminé (relâchement du bouton de la souris).

SDL_KEYDOWN

Les touches du clavier permettent de charger et de sauvegarder le niveau ainsi que de changer l'objet actuellement sélectionné pour le clic gauche de la souris.

case SDL_KEYDOWN:
    switch(event.key.keysym.sym)
    {
        case SDLK_ESCAPE:
            continuer = 0;
            break;
        case SDLK_s:
            sauvegarderNiveau(carte);
            break;
        case SDLK_c:
            chargerNiveau(carte);
            break;
        case SDLK_KP1:
            objetActuel = MUR;
            break;
        case SDLK_KP2:
            objetActuel = CAISSE;
            break;
        case SDLK_KP3:
            objetActuel = OBJECTIF;
            break;
        case SDLK_KP4:
            objetActuel = MARIO;
            break;
    }
    break;

Ce code est très simple. On change l'objet actuel si on appuie sur une des touches numériques, on enregistre le niveau si on appuie sur S, ou on charge le dernier niveau enregistré si on appuie sur C.

Blit time !

Voilà : on a passé en revue tous les événements.
Maintenant, on n'a plus qu'à blitter chacun des éléments de la carte à l'aide d'une double boucle. C'est à peu de choses près le même code que celui de la fonction de jeu. Je vous le redonne, mais pas la peine de vous le réexpliquer ici.

// Effacement de l'écran
SDL_FillRect(ecran, NULL, SDL_MapRGB(ecran->format, 255, 255, 255));

// Placement des objets à l'écran
for (i = 0 ; i < NB_BLOCS_LARGEUR ; i++)
{
    for (j = 0 ; j < NB_BLOCS_HAUTEUR ; j++)
    {
        position.x = i * TAILLE_BLOC;
        position.y = j * TAILLE_BLOC;

        switch(carte[i][j])
        {
            case MUR:
                SDL_BlitSurface(mur, NULL, ecran, &position);
                break;
            case CAISSE:
                SDL_BlitSurface(caisse, NULL, ecran, &position);
                break;
            case OBJECTIF:
                SDL_BlitSurface(objectif, NULL, ecran, &position);
                break;
            case MARIO:
                SDL_BlitSurface(mario, NULL, ecran, &position);
                break;
        }
    }
}

// Mise à jour de l'écran
SDL_Flip(ecran);

Il ne faut pas oublier après la boucle principale de faire lesSDL_FreeSurfacequi s'imposent :

SDL_FreeSurface(mur);
SDL_FreeSurface(caisse);
SDL_FreeSurface(objectif);
SDL_FreeSurface(mario);

Avec ça, le ménage est fait. :-)

Résumé et améliorations

Bien, on a fait le tour. L'heure est au résumé.

Alors résumons !

Et quel meilleur résumé pourrait-on imaginer que le code source complet du programme avec les commentaires ?

Pour éviter de vous proposer des dizaines de pages de code qui répètent tout ce qu'on vient de voir, je vous propose plutôt de télécharger le code source complet du programme ainsi que l'exécutable (compilé pour Windows).

Télécharger le programme + les sources (436 Ko)

Ce fichier .zip contient :

  • l'exécutable pour Windows (si vous êtes sous un autre OS, il suffira de recompiler) ;

  • les DLL de la SDL et deSDL_Image;

  • toutes les images dont a besoin le programme (je vous les ai fait télécharger plus tôt dans le pack « sprites ») ;

  • les sources complètes du programme ;

  • le fichier.cbpde projet Code::Blocks. Si vous voulez ouvrir le projet sous un autre IDE, créez un nouveau projet de type SDL (configurez-le correctement pour la SDL) et ajoutez-y manuellement tous les fichiers.cet.h. Ce n'est pas bien compliqué, vous verrez.

Vous noterez que le projet contient, en plus des.cet des.h, un fichierressources.rc.
C'est un fichier qui peut être ajouté au projet (uniquement sous Windows) et qui permet d'intégrer des fichiers dans l'exécutable. Ici, je me sers du fichier de ressources pour intégrer une icône dans l'exécutable. Cela aura pour effet de donner une icône à l'exécutable, visible dans l'explorateur Windows (fig. suivante).

Intégration d'une icône à l'exécutable

Avouez que c'est quand même plus sympa que d'avoir l'icône par défaut de Windows pour les exécutables !
Vous trouverez plus d'informations sur cette technique sur le cours Créer une icône pour son programme.

Améliorez !

Ce programme n'est pas parfait, loin de là !
Vous voulez des idées pour l'améliorer ?

  • Il manque un mode d'emploi. Affichez un écran d'explications juste avant le lancement d'une partie et avant le lancement de l'éditeur. Indiquez en particulier les touches à utiliser.

  • Dans l'éditeur de niveaux, on ne sait pas quel est l'objet actuellement sélectionné. Ce qui serait bien, c'est que l'objet actuellement sélectionné suive le curseur de la souris. Comme ça, l'utilisateur verrait ce qu'il s'apprête à mettre sur la carte. C'est facile à faire : on a déjà fait un Zozor qui suit le curseur de la souris dans le chapitre précédent !

  • Dans l'éditeur de niveaux, on apprécierait de pouvoir choisir une CAISSE_OK (une caisse bien placée sur un objectif dès le départ). En effet, je me suis rendu compte par la suite qu'il y a de nombreux niveaux qui commencent avec des caisses bien placées dès le départ (ça ne veut pas dire que le niveau est plus facile, loin de là).

  • Dans l'éditeur toujours, il faudrait empêcher que l'on puisse placer plus d'un départ de joueur sur une même carte !

  • Lorsqu'on réussit un niveau, on retourne immédiatement au menu. C'est un peu brut. Que diriez-vous d'afficher un message « Bravo » au centre de l'écran quand on gagne ?

  • Enfin, il serait bien que le programme puisse gérer plus d'un niveau à la fois. Il faudrait que l'on puisse créer une véritable petite aventure d'une vingtaine de niveaux par exemple. C'est un petit peu plus compliqué à coder mais faisable. Il faudra adapter le jeu et l'éditeur de niveaux en conséquence. Je vous suggère de mettre un niveau par ligne dansniveaux.lvl.

Comme promis, pour vous prouver que c'est faisable… je l'ai fait !
Je ne vous donne pas le code source, en revanche (je crois que je vous en ai déjà assez donné jusqu'ici !), mais je vous donne le programme complet compilé pour Windows et Linux.

Le programme comporte une aventure de 20 niveaux (de très très facile à… super difficile).
Pour réaliser certains de ces niveaux, je me suis basé sur le site d'un passionné de Sokoban (sokoban.online.fr).

Voici le Mario Sokoban amélioré pour Windows et Linux :

Téléchargez l'installation du Mario Sokoban amélioré (665 Ko)
Ou bien : Téléchargez la version compilée pour linux au format .tar.gz (64 Ko)

Le programme d'installation a été créé à l'aide d'Inno Setup. Pour plus d'informations, voir le cours Créer une installation.

Exemple de certificat de réussite
Exemple de certificat de réussite