Tutoriel SDL: Animation et gestion du temps

Paru dans Linux Mag 67, décembre 2004

Maintenant que nous savons dessiner sur l'écran et réagir aux entrées, nous pouvons nous lancer dans un peu de technique. Au programme cette fois-ci: l'animation de sprites.

Nous n'entendons pas ici animation au sens déplacement d'un élément graphique sur l'écran, mais plutôt comme une succession d'images fixes à un rythme donné. Chaque élément d'un jeu (vaisseau, projectile, ...) est susceptible de se déplacer à n'importe quel moment. Pour paraître crédible, le mouvement sur l'écran doit s'accompagner d'une animation de l'élément graphique représentant notre objet. Même les objets immobiles gagnent à être animés. Quoi de plus triste qu'un sprite désespérément figé dans l'attente d'un événement... Nous allons mettre au point quelques structures rudimentaires qui nous permettront de gérer facilement et de manière transparente (presque aussi simplement que les éléments non-animés) les sprites animés.

Le module d'animation

Le module que nous allons réaliser sera facilement réutilisable et incorporable dans un projet existant. Il se composera d'un fichier de déclaration (animation.h) et d'un fichier d'implémentation (animation.c) définissant des structures et les fonctions permettant de les manipuler. Ces fonctions peuvent être vues comme des « méthodes » qui s'appliquent à la « classe » dont le nom compose leur préfixe. Le principe des animations est le suivant: des images qui sont affichées les unes après les autres, chaque image restant affichée durant un certain délai. Fixons-nous tout d'abord certaines limites. Toutes les images d'une animation auront la même taille, pour simplifier leur gestion. Les animations seront par défaut cycliques. Enfin, toutes les images d'une animation seront masquées par la couleur 0xff00ff, traditionnellement utilisée pour les masques de couleurs. Commençons par l'écriture du fichier animation.h, qui contiendra les déclarations des structures et fonctions de notre module d'animation. L'en-tête inclura les headers de SDL et définira une macro qui permettra d'éviter les inclusions multiples:

 #ifndef ANIMATION_H__
#define ANIMATION_H__

#include "SDL.h"

L'élément de base d'une animation sera la frame, qui comprend la référence de la surface à afficher et le délai à attendre avant d'afficher la frame suivante:

 typedef struct
{
SDL_Surface * image;
Uint16 delay;
} animation_frame;

Les frames pourront subir 3 opérations: initialisation, nettoyage (libération de la mémoire allouée), et blit sur une autre surface.

 void animation_frame_init(animation_frame * frame, SDL_Surface * image, Uint16 delay);
void animation_frame_cleanup(animation_frame * frame);
void animation_frame_draw(animation_frame * frame, SDL_Surface * dst, SDL_Rect * pos);

Bien que déclarées publiques, ces opérations ne seront vraisemblablement utilisées que de manière interne au module.

Regroupées dans un tableau, les frames forment une animation :

 typedef struct
{
Uint16 nbr_of_frames;
animation_frame * frames;
}animation;

La lecture d'une animation se fait en affichant la frame 0 pendant son délai, puis en passant à la 1, et ainsi de suite. Une fois la dernière frame affichée, on boucle sur la première. Une frame restera indéfiniment affichée si son délai est égal à 0, ce qui nous permettra de créer des animations non-cycliques. La figure 1 illustre les deux structures animation et animation_frame et leur interaction.

Fig. 1: Illustration des structures de l'animation utilisée dans l'exemple de ce mois-ci.
Fig. 1:
Illustration des structures de l'animation utilisée dans l'exemple de ce mois-ci.

De même que pour les frames, une animation aura des fonctions d'initialisation et de nettoyage. Le nombre de frames dont dispose une animation se décide lors de son initialisation. La troisième fonction, animation_setframe, permet d'affecter une surface et un délai à une frame donnée.

 void animation_init(animation * anim, Uint16 nbr_of_frames);
void animation_cleanup(animation * anim);
void animation_setframe(animation * anim, Uint16 pos, SDL_Surface * surface, Uint16 delay);

Les structures animation et animation_frame ne sont rien de plus qu'une description de l'animation: nombre d'images, surface à afficher pour chacune, délai à attendre avant de passer à l'image suivante. On peut comparer une animation à une cassette vidéo ou un DVD: seule, elle est incapable d'afficher quoi que ce soit. Il faut un moyen de lecture, qui fera avancer l'animation et gardera une trace de la position courante.

C'est le rôle de la structure animator. Cette structure est liée à une animation et permet de la jouer. Elle comprend un certain nombre de variables d'état permettant notamment de connaître la position courante dans l'animation:

 typedef struct
{
const animation * anim;
enum { STOP, PLAY } status;
Uint16 current_frame;
Uint16 counter;
}animator;

anim est la référence vers l'animation qui est jouée. status permet de mettre une animation en pause (STOP) ou au contraire de la laisser se jouer (PLAY). current_frame et counter sont respectivement l'index de la frame courante dans l'animation qui est jouée, et le temps écoulé par rapport au délai durant lequel cette frame doit être affichée. La structure animator permet en fait un scénario irréaliste avec notre métaphore de la cassette vidéo: plusieurs animators peuvent être associés à une animation, ce qui serait équivalent à une cassette vidéo qui serait jouée simultanément par plusieurs magnétoscopes, chacun à une position différente. L'avantage pour nous est clair: si une zone de jeu comprend 50 fois le même élément animé, nous n'aurons pas besoin de charger 50 fois en mémoire sa représentation graphique: elle sera présente une fois en mémoire, mais utilisée par autant d'animators qu'il y aura de présences à l'écran, comme l'illustre la figure 2.

Fig. 2
Fig. 2:
Les animators permettent de séparer l'état d'une animation de ses données, et ainsi de jouer plusieurs fois la même animation simultanément.

N'allouant pas de mémoire, animator dispose d'une fonction d'initialisation qui prend en argument l'animation à jouer, mais pas de fonction de nettoyage.

 void animator_init(animator * ator, animation * anim);

Trois autres fonctions permettent de manipuler l'état de lecture de l'animation, pour la jouer, l'arrêter et la remettre dans son état initial:

 void animator_play(animator * ator);
void animator_stop(animator * ator);
void animator_rewind(animator * ator);
void animator_nextframe(animator * ator);

La fonction nextframe, elle, passe directement à la frame suivante quel que soit l'état de l'animator. Elle est utilisée en interne mais permet aussi de faire des animations dont le déroulement est géré par évènements: pour cela, on mettra les délais de toutes les frames à 0 et on appellera nextframe lorsque l'on souhaite passer à la frame suivante.

Enfin, les deux dernières fonctions sont également les principales : update incrémente le compteur interne de l'animator et met à jour la frame à afficher si nécessaire. draw dessine la frame courante sur la surface et à la position données.

 void animator_update(animator * ator);
void animator_draw(animator * ator, SDL_Surface * dest, SDL_Rect * pos);

#endif

La fonction animator_update doit être appelée régulièrement pour obtenir des animations cohérentes. Le délai que nous définissons n'a en effet aucune valeur temporelle. Si une frame a un délai de 15, il faudra appeler 15 fois animator_update pour passer à la frame suivante.

L'implémentation de ces fonctions est réalisée dans le fichier animation.c, dont voici le listing, assez trivial si l'on a compris le fonctionnement des animations:

 #include "animation.h"
#include <stdlib.h>

void animation_frame_init(animation_frame * frame, SDL_Surface * image, Uint16 delay) {
frame->image = image;
frame->delay = delay;
}

void animation_frame_cleanup(animation_frame * frame) {
if (frame->image) SDL_FreeSurface(frame->image);
}

void animation_frame_draw(animation_frame * frame, SDL_Surface * dest, SDL_Rect * pos) {
SDL_BlitSurface(frame->image, (SDL_Rect *)NULL, dest, pos);
}

void animation_init(animation * anim, Uint16 nbr_of_frames) {
anim->frames = (animation_frame *) calloc(nbr_of_frames, sizeof(animation_frame));
anim->nbr_of_frames = nbr_of_frames;
}


void animation_setframe(animation * anim, Uint16 pos, SDL_Surface * surface, Uint16 delay) {
animation_frame_cleanup(&anim->frames[pos]);
animation_frame_init(&anim->frames[pos], surface, delay);
}


void animation_cleanup(animation * anim) {
int i;
for (i = 0; i < anim->nbr_of_frames; i++)
animation_frame_cleanup(&anim->frames[i]);

free(anim->frames);
}


void animator_init(animator * ator, animation * anim) {
ator->anim = anim;
animator_rewind(ator);
}

void animator_play(animator * ator) {
ator->status = PLAY;
}

void animator_stop(animator * ator) {
ator->status = STOP;
}

void animator_rewind(animator * ator) {
ator->current_frame = 0;
ator->counter = 0;
animator_stop(ator);
}

void animator_nextframe(animator * ator) {
/* Retour à la frame 0 si nous sommes à la dernière */
if (++ator->current_frame == ator->anim->nbr_of_frames) ator->current_frame = 0;
ator->counter = 0;
}

void animator_update(animator * ator) {
const animation_frame * frame;

/* Ne mettre à jour l'animation que si elle est jouée */
if (ator->status != PLAY) return;

frame = &ator->anim->frames[ator->current_frame];
if (frame->delay == 0) return;

/* Passage à la frame suivante? */
if (++ator->counter == frame->delay) animator_nextframe(ator);
}


void animator_draw(animator * ator, SDL_Surface * dest, SDL_Rect * pos) {
animation_frame_draw(&ator->anim->frames[ator->current_frame], dest, pos);
}

Gestion du temps

Nous allons également nous préoccuper un peu du caractère temporel de nos programmes. Ils seront amenés à s'exécuter sur des machines de puissance et de configuration différentes, et ne fonctionneront donc pas exactement de la même manière. SDL offre une abstraction permettant de ne pas avoir à prendre en compte les différences d'architectures et de matériel, il nous reste cependant à gérer le cas où une machine fait tourner notre programme tellement vite que cela en devient injouable ou, plus fréquent, n'est pas assez puissante l'exécuter de manière optimale.

Il s'agit d'une situation connue de tous les joueurs: un jeu se met à « ramer ». Deux attitudes de sa part sont alors possibles: soit la cadence du jeu se ralentit, et le jeu devient plus « lent », soit elle reste la même mais des « saccades » apparaissent alors.

Bien qu'aucune de ces situations ne soit vraiment agréable, il est en général préférable de tomber dans la seconde que dans la première. Le premier comportement est ce qui arrive si aucune prise en charge du temps n'a été prévue. Nous allons voir comment implémenter le second.

Il existe plusieurs manières de procéder, mais la suivante est à la fois simple et efficace. Tout d'abord, nous allons fixer un « rythme » pour notre programme, exprimé en cycles par secondes. Un cycle correspondant à la mise à jour de son l'état interne (position des sprites, mise à jour des animations, etc). Ce rythme, quoi qu'il en coûte, devra toujours être respecté. Si notre programme détecte qu'il a pris du retard, il lui faudra trouver un moyen de faire moins de calculs pour rattraper ce retard. Qu'allons-nous alors sacrifier? Le feedback à l'utilisateur, c'est à dire l'affichage. En général, la mise à jour de l'écran prend un temps très condérable, souvent la plus grosse partie du temps d'exécution d'un jeu, en particulier si la cible n'est pas accélérée. Nous allons donc avoir un deuxième « rythme », le nombre d'images par secondes, qui idéalement sera égal au nombre de cycles de jeu par secondes mais pourra être inférieur si besoin est. Autrement dit, si la machine sur laquelle tourne le programme est trop lente pour honorer ses délais, nous « sauterons » le nombre d'images nécessaires pour que l'état interne du programme puisse au moins être mis à jour en temps et en heure.

Afin de pouvoir en arriver là, il est nécessaire de bien séparer l'affichage du jeu en lui-même. C'est d'ailleurs une bonne pratique en programmation et le reflet d'un bon design. Un jeu n'a besoin de l'affichage que pour montrer son état interne au joueur. Il peut et doit être capable de fonctionner sans prendre ce dernier en compte.

Notre petit module d'animation prend déjà cette séparation en compte. La structure animator contient l'état d'une animation, la structure animation contient ses éléments graphiques. animation dépend de SDL dont elle utilise des éléments, animator en est indépendante (en dehors des types primitifs comme Uint16, mais ceux-ci peuvent être redéfinis). On pourrait ainsi remplacer notre implémentation de animation utilisant SDL par une autre utilisant une autre bibliothèque graphique et continuer à l'utiliser avec animator sans changer cette dernière. C'est un de effets de bord bénéfiques d'un bon design et donc d'une bonne séparation des préoccupations.

Mais revenons-en à notre gestion du temps. Nous allons l'implémenter dans time.h et time.c, de la même manière que nous avons fait pour les animations. SDL offre une fonction qui permet de connaître le nombre de millisecondes qui se sont écoulées depuis l'initialisation de la bibliothèque:

 Uint32 SDL_GetTicks(void);

Nous pouvons nous servir de cette fonction pour mesurer le temps qui s'est écoulé entre deux cycles. Pour cela, il nous faudra une fonction qui soit appelée à chaque cycle de jeu pour battre la cadence. C'est le rôle de notre fonction de synchronisation, time_update, qui a pour vocation d'être appelée dans la boucle de jeu et va réaliser les tâches suivantes:

Deux autres fonctions font partie de notre module de gestion du temps, time_init qui l'initialise aux valeurs par défaut, et time_set_game_speed qui permet de changer la vitesse du jeu, en nombre de cycles par secondes.

Voici les listings de time.h et time.c:

time.h:

 #ifndef TIME_H__
#define TIME_H__

#include "SDL.h"

extern Uint16 cycles_to_calculate;
extern Uint16 game_speed;
extern Uint16 cycle_length;

void time_init();
void time_set_game_speed(Uint16 speed);
void time_update();

#endif

time.c:

 #include "time.h"

static Uint32 timer1, timer2;

/* Nombre maximum de cycles à passer - au delà, le jeu
sera ralenti */
#define MAX_SKIPPED_FRAMES 20
/* Variable globale indiquand le nombre de cycles à
calculer avant d'afficher une image */
Uint16 cycles_to_calculate = 0;

/* Vitesse par défaut du jeu, en cycles par seconde */
#define DEFAULT_GAME_SPEED 70
/* Vitesse courante du jeu */
Uint16 game_speed;
/* Durée d'un cycle en millisecondes */
Uint16 cycle_length;

/* Initialise le système temporel */
void time_init() {
time_set_game_speed(DEFAULT_GAME_SPEED);
}

/* Affecte la vitesse du jeu, en cycles par seconde */
void time_set_game_speed(Uint16 speed) {
if (speed == 0) speed = 1;
game_speed = speed;
cycle_length = 1000 / game_speed;
timer1 = SDL_GetTicks();
}

/* Mise à jour du temps et des variables associées */
void time_update() {
/* S'assurer que l'on ne va pas trop vite... */
while (1) {
timer2 = SDL_GetTicks() - timer1;
if (timer2 >= cycle_length) break;
else SDL_Delay(3);
}

/* Mise à jour de l'état des timers et des variables
globales - timer1 va contenir le "surplus" de
timer2 par rapport au temps que l'on aurait exactement
dû passer, cycles_to_calculate le nombre de cycles à
faire jouer avant d'afficher une nouvelle image */
timer1 = SDL_GetTicks() - (timer2 % cycle_length);
cycles_to_calculate = timer2 / cycle_length;
/* Vérifier la limite des cycles à sauter */
if (cycles_to_calculate > MAX_SKIPPED_FRAMES) cycles_to_calculate = MAX_SKIPPED_FRAMES;
}

Le programme du mois

Comme il se doit, notre programme d'exemple va utiliser ces deux modules pour créer un petit effet graphique. Il crée une animation correspondant à l'explosion illustrée en figure 1 et l'afficher plusieurs fois à l'écran. Le nombre d'explosions simultanées est contrôlé par deux tableaux, l'un d'animators, l'autre de SDL_Rects. Chaque couple correspond à une lecture de l'animation et à sa position à l'écran. Les touches + et -du pavé numérique permettent d'augmenter ou de diminuer le nombre d'explosions simultanées. Le module de gestion du temps est utilisé pour donner le rythme du jeu. Par défaut, le jeu tourne à 70 cycles par secondes. En utilisant les touches PageUp et PageDown, il est possible d'augmenter ou de diminuer de rythme par palliers de 5 cycles par seconde. Pour permettre de mesurer les performances de la machine, le nombres d'images par secondes est affiché toutes les secondes sur la console. Idéalement, il sera à peu près égal au nombre de cycles par secondes (une légère imprécision peut donner des chiffres légèrement supérieurs ou inférieurs), mais si le rythme est trop soutenu, ou que la machine est chargée par d'autres programmes, vous allez voir qu'il va rapidement tomber. Le jeu, en revanche, gardera la même vitesse. Voici le listing de ce programme, à mettre dans main.c:

 #include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "SDL.h"

#include "animation.h"
#include "time.h"

char letsexit = 0;

/* Nombre d'explosions simultanées et valeur
par défaut */
#define DEFAULT_EXPLO 10
static int nb_explo;

/* L'animation et les tableaux contenant les
animators ainsi que leurs positions */
static animation anim;
static animator * player = NULL;
static SDL_Rect * pos = NULL;

SDL_Surface * screen;

/* Réalloue la taille des tableaux pour
n explosions */
void allocate_explosions(int n) {
nb_explo = n;
player = (animator *) realloc(player, sizeof(animator) * nb_explo);
pos = (SDL_Rect *) realloc(pos, sizeof(SDL_Rect) * nb_explo);
}


/* Initialise une animation nouvellement allouée */
void init_explosion(int i) {
/* Position aléatoire sur l'écran */
pos[i].x = rand() % (screen->w - player[i].anim->frames[0].image->w);
pos[i].y = rand() % (screen->h - player[i].anim->frames[0].image->h);
/* Position aléatoire dans l'animation */
player[i].current_frame = rand() % player[i].anim->nbr_of_frames;
player[i].counter = rand() % player[i].anim->frames[player[i].current_frame].delay;
}

/* Gestion des entrées clavier */
void input_update() {
SDL_Event event;
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_KEYDOWN:
switch (event.key.keysym.sym) {
/* Quitter */
case SDLK_ESCAPE:
letsexit = 1;
break;
/* Plus d'explosions */
case SDLK_KP_PLUS:
allocate_explosions(nb_explo + 1);
animator_init(&player[nb_explo - 1], &anim);
animator_play(&player[nb_explo - 1]);
init_explosion(nb_explo - 1);
printf("Nombre d'explosions: %d\n", nb_explo);
break;
/* Moins d'explosions */
case SDLK_KP_MINUS:
if (nb_explo == 0) break;
allocate_explosions(nb_explo - 1);
printf("Nombre d'explosions: %d\n", nb_explo);
break;
/* Plus rapide */
case SDLK_PAGEUP:
time_set_game_speed(game_speed + 5);
printf("Vitesse de jeu: %d cycles/seconde\n", game_speed);
break;
/* Moins rapide */
case SDLK_PAGEDOWN:
if (game_speed < 5) time_set_game_speed(1);
else time_set_game_speed(game_speed - 5);
printf("Vitesse de jeu: %d cycles/seconde\n", game_speed);
break;
default:
break;
}
break;
default:
break;
}
}
}


int main(int argc, char * argv[]) {
SDL_Surface * img;
int i;
if (SDL_Init(SDL_INIT_VIDEO) == -1) {
printf("Erreur lors de l'initialisation de SDL!\n");
return 1;
}
screen = SDL_SetVideoMode(640, 480, 24,
SDL_HWSURFACE | SDL_DOUBLEBUF | SDL_ANYFORMAT);

/* Création de l'animation */
animation_init(&anim, 3);
/* Frame 0, délai 10 cycles */
img = SDL_LoadBMP("explo1.bmp");
SDL_SetColorKey(img, SDL_SRCCOLORKEY, SDL_MapRGB(img->format, 0xff, 0x00, 0xff));
animation_setframe(&anim, 0, img, 10);
/* Frame 1, délai 15 cycles */
img = SDL_LoadBMP("explo2.bmp");
SDL_SetColorKey(img, SDL_SRCCOLORKEY, SDL_MapRGB(img->format, 0xff, 0x00, 0xff));
animation_setframe(&anim, 1, img, 15);
/* Frame 2, délai 30 cycles */
img = SDL_LoadBMP("explo3.bmp");
SDL_SetColorKey(img, SDL_SRCCOLORKEY, SDL_MapRGB(img->format, 0xff, 0x00, 0xff));
animation_setframe(&anim, 2, img, 30);
/* Allocation de initialisation des explosions et de leur position */
allocate_explosions(DEFAULT_EXPLO);
for (i = 0; i < nb_explo; i++) {
animator_init(&player[i], &anim);
animator_play(&player[i]);
init_explosion(i);
}
/* Initialisation du temps avant la boucle principale */
time_init();
/* Ces variables nous permettront de calculer les FPS
(frames per second, images par seconde) */
Uint32 timer1 = SDL_GetTicks(), timer2;
int frames_per_second = 0;
/* Boucle de jeu */
while (!letsexit) {
int k;
/* Mise à jour des entrées et du temps */
input_update();
time_update();
/* Remplissage de l'écran en bleu */
SDL_FillRect(screen, NULL, SDL_MapRGB(screen->format, 0x00, 0x00, 0xff));
/* Mise à jour de l'état interne du jeu. cycles_to_calculate nous indique
de combien de cycles avancer pour garder une vitesse cohérente */
for (k = 0; k < cycles_to_calculate; k++) {
/* Mise à jour des explosions */
for (i = 0; i < nb_explo; i++) {
animator_update(&player[i]);

/* Si l'animation vient de boucler... */
if (player[i].current_frame == 0 && player[i].counter == 0) {
/* ... on lui change sa position aléatoirement */
pos[i].x = rand() % (screen->w - img->w);
pos[i].y = rand() % (screen->h - img->h);
}
}
}
/* Dessiner les explosions sur l'écran */
for (i = 0; i < nb_explo; i++)
animator_draw(&player[i], screen, &pos[i]);
/* Afficher l'écran que l'on vient de dessiner */
SDL_Flip(screen);
/* Calculer les fps */
frames_per_second++;
timer2 = SDL_GetTicks();
/* Une seconde s'est écoulée? Afficher le nombre d'écrans
dessinés et réinitialiser */
if (timer2 - timer1 > 1000) {
printf("FPS: %d\n", frames_per_second);
timer1 = timer2;
frames_per_second = 0;
}
}
/* Libération des ressources allouées */
free(player);
free(pos);
animation_cleanup(&anim);
SDL_Quit();
return 0;
}

Comme d'habitude, vous retrouverez le listing complet ainsi que les images utilisées se trouvent sur le CD-ROM, ou sur http://www.gnurou.org/documents/linuxmag/SDL/SDL-3.tar.gz. La compilation se fait de la manière suivante (ou mieux, en utilisant un Makefile):

 $ gcc `sdl-config --cflags --libs` animation.c time.c main.c -o test

Fig. 3: Le programme du mois
Fig. 3:
Le programme du mois.

La prochaine fois

Nous voilà maintenant avec les bases nécessaires pour créer des animations plus évoluées. La prochaine fois, nous changerons un peu de registre en étudiant des effets graphiques calculés, comme l'effet de feu ou le champ d'étoiles. Ces effets sont étonnants à deux titres: leur simplicité d'implémentation et le résultat visuel. D'ici là, codez bien!