mystnb/tutorials/02-un-peu-de-logique-tempor...

529 lines
43 KiB
Plaintext

[center][big][brown][b]02 : Un peu de logique temporelle ![/b][/brown][/big][/center]
Maintenant qu'on s'est un peu échauffés avec le menu principal, il est temps de regarder comment on va coder le moteur de notre jeu. Voilà ce qu'on va faire dans ce tutoriel :
• Créer un plateau de jeu vide de la taille de l'écran (sans scrolling) ;
• Ajouter un personnage et des animations ;
• Prendre des entrées clavier pendant que les animations tournent.
Pour l'instant la map sera juste un rectangle vide avec des bords, le minimum nécessaire pour déplacer le joueur sans sortir des bornes du niveau. On verra dans le prochain tutoriel comment encoder proprement la map. Notre objectif est d'atteindre le résultat suivant ! :D
[center][img]https://gitea.planet-casio.com/Lephenixnoir/mystnb/raw/branch/master/tutorials/gint-tuto-02-animated-movement.gif[/img]
[i]Image 1 : Full graphismes et zéro gameplay. Que demande le peuple ?[/i][/center]
Ce tutoriel contient pas mal de code spécifique au jeu qu'on est en train de créer et un peu moins de code gint que le précédent. Cependant, je pense qu'il est utile de réfléchir ensemble à comment séparer les différentes parties du jeu pour rendre le code flexible et élégant à l'échelle du projet complet. Ça peut vous sembler superflu à ce stade, mais il faut réaliser que vous transformez sans cesse le code de votre jeu pour ajouter ou modifier des fonctions, et s'il est mal codé la structure ne [i]résistera[/i] pas. Et donc autant commencer tout de suite à faire les choses bien. ;)
On arrivera vite au point où le code et les fonctions de gint se complexifient par rapport au programme simple de la dernière fois. Je continuerai à expliquer les aspects du langage C au fur et à mesure, mais ce tutoriel ne peut pas être un cours complet de C, donc n'hésitez pas à en consulter un (par exemple [url=https://zestedesavoir.com/tutoriels/755/le-langage-c-1/]celui de Zestes de Savoir[/url]) ou à poser des questions [url=https://www.planet-casio.com/Fr/forums/topic14915-1-tutoriels-dutilisation-de-gint-commentaires.html]sur le topic des commentaires[/url] si quelque chose vous échappe. ^^
[brown][b]Les bases du moteur : joueurs, niveaux et parties[/b][/brown]
On va tout de suite commencer par définir les types fondamentaux du moteur. Je vais le faire dans deux nouveaux fichiers, `engine.h` et `engine.c`. Lorsqu'on programe avec plusieurs fichiers, il est important que chaque fichier source (`.c`) possède un en-tête associé (`.h`) qui récapitule les fonctions et types qui y sont définis. C'est parce que le compilateur compile tous les fichiers sources indépendamment les uns des autres, et c'est cette information qui lui permet de se faire une vision d'ensemble. Vous pouvez trouver de plus amples détails dans le [url=https://www.planet-casio.com/Fr/forums/topic15378-1-tdm-n07-ecrire-des-programmes-c-avec-plusieurs-fichiers.html]TDM n°7[/url] sur ce sujet.
En pratique, le fichier d'en-tête contient une description des types de données manipulés par le fichier source associé et les prototypes de ses fonctions publiques. C'est une sorte de « résumé » si vous voulez.
On n'oublie pas qu'il faut ajouter les fichiers source au `CMakeLists.txt` pour qu'ils soient compilés. Les fichiers d'en-tête sont détectés automatiquement donc on n'y touche pas.
[code]set(SOURCES
src/main.c
src/engine.c
# ...
)[/code]
Je vais commencer par ajouter dans `engine.h` la définition d'une structure `player` qui décrit l'information complète d'un joueur présent sur la map. J'en profite aussi pour choisir des valeurs pour désigner les quatre directions donc j'aurai souvent besoin.
[code]#ifndef _MYSTNB_ENGINE_H
#define _MYSTNB_ENGINE_H
/* Directions */
#define DIR_DOWN 0
#define DIR_RIGHT 1
#define DIR_UP 2
#define DIR_LEFT 3
/* struct player: A player on the map, triggering actions as they move */
struct player
{
/* Position in map */
int x, y;
/* Direction currently facing */
int dir;
/* Animation and frame */
struct animation const *anim;
int frame;
};
#endif /* _MYSTNB_ENGINE_H */[/code]
Vous noterez que le fichier commence et se termine par des instructions préprocesseur (lignes qui commencent par `#`). L'objectif de ces lignes est de faire en sorte que les contenus de `engine.h` ne soient lus qu'une seule fois par le compilateur, même si le fichier est inclus plusieurs fois (ce qui arrive souvent quand des en-têtes incluent d'autres en-têtes). Ça marche de la façon suivante : le contenu du fichier n'est lu que si la macro `_MYSTNB_ENGINE_H` n'est pas définie (ça c'est le `#ifndef`), et si c'est le cas alors on lit le fichier et on la définit immédiatement (ça c'est le `#define`). Du coup si on réinclut l'en-tête le `#ifndef` ne passera plus et tout sera ignoré jusqu'à `#endif` tout à la fin.
On fait ça est parce que le compilateur serait très mécontent de voir apparaître deux `struct player`, même si les contenus sont identiques. C'est comme si vous écrivez `int x = 42` deux fois : c'est pas parce que c'est deux fois le même nom et la même valeur que le compilateur va considérer que c'est la même variable. Si tout ça vous échappe un peu, pas d'inquiétude : vous verrez cette construction dans tous les fichiers d'en-tête et vous la connaîtrez par coeur avant d'avoir pu dire « préprocesseur ». :p
Quant à la définition de la structure à proprement parler, rien de bien impressionnant. On y trouve :
• La position du joueur sur la map ;
• La direction où il regarde (pour choisir le bon sprite à afficher) ;
• Et l'animation en cours et le frame où on en est. J'y reviendrai plus tard. Je n'ai pas défini `struct animation` mais c'est pas grave, on a le droit de créer un pointeur vers un type même s'il n'est pas encore bien défini (on dit « incomplet »).
Si vous n'êtes pas encore tout à fait familier·ère avec les structures, c'est le bon moment de se rappeler que cette définition ne crée aucune variable, elle explique simplement ce que les variables de type `struct player` contiendront quand on créera.
On a également besoin de la spritesheet pour pouvoir faire notre affichage. Voici à quoi elle ressemble ; comme d'habitude, ne récupérez pas cette image (qui est agrandie), prenez plutôt [url=https://gitea.planet-casio.com/Lephenixnoir/mystnb/src/commit/8dba2daeb3c9321d8eb284df0b9076e18addf0c4/assets-fx/spritesheet.png]la version originale sur le dépôt[/url]. Enregistrez-la sous le nom `assets-fx/spritesheet.png`.
[center][img]https://gitea.planet-casio.com/Lephenixnoir/mystnb/raw/branch/master/tutorials/gint-tuto-02-spritesheet.png[/img]
[i]Image 2 : Pour les diagonales, on repassera.[/i][/center]
Je profite de l'occasion pour améliorer le `fxconv-metadata.txt`. Pour l'instant, on a listé tous les fichiers à la main dedans, mais ça devient vite répétitif, surtout qu'il n'y a pas de paramètres particuliers. Nos images ressemblent à ça :
[code]levels.png:
type: bopti-image
name: img_levels
title.png:
type: bopti-image
name: img_title
spritesheet.png:
type: bopti-image
name: img_spritesheet[/code]
Ce serait bien de pouvoir créer un bloc qui traite toutes les images d'un coup et ensuite on n'en parle plus. Et ça tombe bien, car ça existe : on peut spécifier un wildcard dans le nom, par exemple `*.png`.
[code]*.png:
type: bopti-image
name: ...[/code]
Par contre on a un problème : on ne peut plus spécifier le nom puisqu'on ne connaît pas le nom du fichier (il est caché par l'étoile). Pour rattraper la situation, [brown][b]fxconv[/b][/brown] propose un paramètre `name_regex` qui permet d'analyser le nom du fichier et de calculer le nom de la variable avec le résultat de l'analyse.
[code]*.png:
type: bopti-image
name_regex: (.*)\.png img_\1[/code]
Si vous ne connaissez pas les expressions regulières (regex), ne vous inquiétez pas de la magie en jeu ici. Essentiellement, `(.*)\.png` signifie « le nom du fichier ressemble à `xxxx.png`, trouve-moi `xxxx` ». Et ensuite `img_\1` signifie « maintenant nomme-moi la variable `img_xxxx` ». Il vous suffit de le copier-coller aujourd'hui, vous n'aurez jamais besoin d'y retourner si ça ne vous intéresse pas. ^^
Avec ce code, toutes les images `.png` dans le dossier seront converties automatiquement. Il y a juste deux détails importants pour que ça ne gêne pas la conversion de la police :
• Les blocs se lus de haut en bas et les informations sont combinées. Par exemple pour `font_mystere.png`, le `type` et le `name_regex` [i]vont s'appliquer[/i], par contre le bloc `font_mystere.png` va s'appliquer aussi et remplacer le `type` et ajouter un `name`.
• Quand `name_regex` et `name` sont spécifiés tous les deux, `name` a la priorité.
Et on n'oublie pas d'ajouter les fichiers à `CMakeLists.txt` dans tous les cas. ^^
[code]set(ASSETS_fx
assets-fx/levels.png
assets-fx/title.png
assets-fx/spritesheet.png
assets-fx/font_mystere.png
# ...
)[/code]
Revenons à la conception du jeu.
Le joueur se situe bien sûr à l'intérieur d'un niveau que j'appelle [i]map[/i] par pure commodité. C'est simplement une grille avec des espaces libres, des murs, des portes, et quelques items importants placés au sol. Pour l'instant on ne vas pas trop se demander comment les représenter et les convertir (ce sera dans le tutoriel suivant), on va juste donner une version vague de la structure avec les dimensions de la map histoire de pouvoir déplacer le joueur sans sortir des bords.
[code]/* struct map: A map with moving doors and collectibles */
struct map
{
/* Width and height */
int w, h;
/* Raw data */
uint8_t *data;
};[/code]
En fait tous les niveaux que j'ai prévus ont la même taille (sensiblement la taille de l'écran), mais ce serait dommage de ne pas se donner un peu de latitude au cas où le jeu évolue dans le futur. Les données brutes ce sera un tableau avec l'état de chaque cellule de la map, stay tuned. Dans l'immédiat, cette structure nous sera utile pour vérifier que le joueur ne sort pas de la map, c'est-à-dire qu'on veut assurer à tout instant :
• `player.x >= 0 && player.x < map.w`
• `player.y >= 0 && player.y < map.h`
Et comme j'aime bien tout formaliser (c'est un défaut de théoricien), je ne vais pas juste me balader deux variables `player` et `map`, je vais mettre tout ça dans une structure représentant la partie en cours. Ce qui m'incite d'ailleurs à faire un [i]tableau[/i] de joueurs, parce que qui sait, le jeu pourrait évoluer dans le futur. :whistle:
[code]#define PLAYER_COUNT 1
/* struct game: A running game with a map and some players */
struct game
{
/* Current map */
struct map *map;
/* Players */
struct player *players[PLAYER_COUNT + 1];
};[/code]
Comme le nombre de joueurs n'est pas fixe a priori, j'ai juste donné un maximum (`PLAYER_COUNT`) et le tableau se terminera par un `NULL` après le dernier joueur.
Puisque les animations viendront après, on a sensiblement toutes les informations nécessaires pour jouer une partie de mystère noir et blanc... ou plus précisément de mystère blanc étant donné qu'on n'a rien sur la map ! :E
[brown][b]Structure du programme : affichage, logique, et entrées[/b][/brown]
Un point sur lequel je veux m'attarder dès maintenant est la structure du code. Le jeu a pas mal de choses à faire pour fonctionner correctement, et il est important de ne pas les mélanger pour conserver un code extensible qui résistera à tous les tests et modifications qu'on fera pendant le développement.
Ici, on va séparer les tâches en trois catégories :
1. Affichage de l'état du jeu à l'écran (map, joueur et animations)
2. Logique interne et physique (déplacement du joueur, collisions, ouverture des portes)
3. Acquisition des entrées (saisie au clavier et traduction en « instructions »)
Ces trois parties sont sensiblement indépendantes et il est utile de les coder comme telles pour se donner de la flexibilité. Par exemple, il n'y a pas de raison que la vitesse des animations soit liées à la détection des touches. De même, la vitesse des frames à l'écran n'est pas forcément celle des updates internes (FPS vs UPS). (Ici le jeu est au tour par tour donc c'est facile car les updates sont calées sur les saisies au clavier, mais dans un jeu en temps réel la question se poserait.)
L'indépendance doit donc être explicite dans le code, et dans mon cas je vais donner les règles suivantes :
1. L'affichage ne peut que lire les structures du jeu et dessiner à l'écran.
2. La logique ne peut que lire les instructions du joueur et modifier les structures du jeu (joueur, map, etc).
3. La gestion des entrées ne peut que lire le clavier et produire des instructions.
Tout ça sera lié par la fonction principale du programme qui passera les sorties des uns en entrée aux autres et réglera le timing. ^^
[brown][b]Affichage de l'état du jeu à l'écran[/b][/brown]
Commençons par l'affichage de l'état du jeu. Pour l'instant il n'y a pas grand-chose à faire étant donné qu'on n'a que le joueur et pas de map. Par simplicité, je me donne une macro pour calculer la position d'une cellule de la map à l'écran (en supposant qu'on ne scrolle pas) et ça permet tout de suite d'écrire une fonction pour afficher le joueur depuis la spritesheet.
[code]#define CELL_X(x) (-2 + 10 * (x))
#define CELL_Y(y) (-3 + 10 * (y))
static void engine_draw_player(struct player const *player)
{
extern bopti_image_t img_spritesheet;
dsubimage(CELL_X(player->x) - 1, CELL_Y(player->y) - 5,
&img_spritesheet, player->dir * 12, 0, 12, 16, DIMAGE_NONE);
}[/code]
Rien de bien inattendu ici, juste une utilisation un peu maline de `dsubimage()`. Comme vous pouvez le voir, je prends un rectangle de taille 12x16 à la position `player->dir * 12` horizontalement et `0` verticalement : c'est donc le premier frame de l'animation [i]"idle"[/i] pour la direction actuelle du joueur. Dans la deuxième partie de ce tutoriel on ajoutera des vraies animations et donc on viendra modifier cette fonction en conséquence.
[center][img]https://gitea.planet-casio.com/Lephenixnoir/mystnb/raw/branch/master/tutorials/gint-tuto-02-spritesheet-frame1.png[/img]
[i]Image 3 : Tout ce travail et j'utilise qu'un seul frame ![/i][/center]
Pour information, le choix de `CELL_X()` et `CELL_Y()` donne une grille qui a la tête suivante à l'écran. Une partie des cellules est masquée par le bord de l'écran pour gagner de la place.
[center][img]https://gitea.planet-casio.com/Lephenixnoir/mystnb/raw/branch/master/tutorials/gint-tuto-02-mapgrid.png[/img]
[i]Image 4 : C'est asymétrique spécifiquement pour vous embêter.[/i][/center]
Et tout cela suffit pour afficher le jeu complet puisqu'on n'a pas de map.
[code]void engine_draw(struct game const *game)
{
dclear(C_WHITE);
for(int p = 0; game->players[p]; p++)
{
engine_draw_player(game->players[p]);
}
}[/code]
Dans la boucle `for`, la condition de sortie `game->players[p]` est équivalente à `game->players[p] != NULL`, donc j'itère sur tous les joueurs présents sur la map pour les afficher.
[brown][b]Logique interne et physique[/b][/brown]
Voyons maintenant comment on peut se déplacer. Comme mentionné tout à l'heure, on ne veut pas que le joueur puisse sortir de la map ni marcher dans les murs. Du coup j'ajoute une fonction `map_walkable()`, qui doit vérifier qu'une case est accessible (ie. pas un mur) et hardcode actuellement des murs sur les bords, comme sur l'image ci-dessous.
[center][img]https://gitea.planet-casio.com/Lephenixnoir/mystnb/raw/branch/master/tutorials/gint-tuto-02-mapbounds.png[/img]
[i]Image 5 : Contrairement à Portal, il n'y a pas vraiment de sortie.[/i][/center]
[code]/* Check whether a cell of the map is walkable */
static int map_walkable(struct map const *map, int x, int y)
{
return (x >= 1) && (y >= 1) && (x < map->w - 1) && (y < map->h - 1);
}[/code]
À partir de là, on peut écrire une fonction qui tente de déplacer un joueur dans une direction choisie. La fonction renvoie 0 si le déplacement échoue et 1 s'il réussit, car cette information est nécessaire pour décider si le tour est terminé (il se passera des choses avec les portes entre chaque tour de jeu).
[code]int engine_move(struct game *game, struct player *player, int dir)
{
int dx = (dir == DIR_RIGHT) - (dir == DIR_LEFT);
int dy = (dir == DIR_DOWN) - (dir == DIR_UP);
/* Always update the direction */
player->dir = dir;
/* Only move the player if the destination is walkable */
if(!map_walkable(game->map, player->x + dx, player->y + dy)) return 0;
player->x += dx;
player->y += dy;
return 1;
}[/code]
Notez l'astuce consistant à calculer le déplacement en x et en y avec une différence de booléens pour ne pas s'embêter à faire des `if/else` ou un `switch`.
[brown][b]Acquisition des entrées[/b][/brown]
La gestion des entrées sera très basique pour l'instant donc je l'ai laissée dans `main.c`. Le but de cette fonction est simplement de lire les entrées clavier et de renvoyer la direction dans laquelle le joueur demande à se déplacer. On va donc juste chercher `KEY_DOWN`, `KEY_RIGHT`, `KEY_UP` et `KEY_LEFT` et les renvoyer vers leurs `DIR_*` associés. La paragraphe qui vient après est long simplement parce que c'est une bonne occasion de parler de la fonction `getkey_opt()`. :)
[code]/* Returns a direction to move in */
static int get_inputs(void)
{
int opt = GETKEY_DEFAULT & ~GETKEY_REP_ARROWS;
while(1)
{
int key = getkey_opt(opt, NULL).key;
if(key == KEY_DOWN) return DIR_DOWN;
if(key == KEY_RIGHT) return DIR_RIGHT;
if(key == KEY_UP) return DIR_UP;
if(key == KEY_LEFT) return DIR_LEFT;
}
}[/code]
Une des fonctionnalités assurées par `getkey()` est la répétition des touches. Si vous gardez une touche appuyée, `getkey()` est susceptible de la renvoyer plusieurs fois. Par défaut, seules les touches fléchées sont répétées (comme `GetKey()`), ce qui permet par exemple de se déplacer dans des listes ou des menus. Mais ici, comme le jeu est un puzzle nécessitant de bien réfléchir à chaque coup, on veut que chaque pression déplace le joueur exactement une fois (sinon on risque un faux mouvement qui gâcherait le niveau).
C'est ici que la fonction `getkey_opt()` entre en jeu. `getkey_opt()` est une version générale de `getkey()` (définie dans `<gint/keyboard.h>`) qui possède plein d'options pour personnaliser la façon dont vous lisez le clavier. Il y a deux arguments : d'abord les options et ensuite le timeout. Expliquer tout le comportement de la fonction serait long, donc je vais juste présenter rapidement les options.
• `GETKEY_MOD_SHIFT` et `GETKEY_MOD_ALPHA` activent l'utilisation de SHIFT et ALPHA comme des modifieurs, pour former des combinaisons comme SHIFT+sin → asin. Lorsque ces options sont activées, `getkey_opt()` ne renvoie jamais d'événement ayant `.key == KEY_SHIFT` ou `.key == KEY_ALPHA` et attend à la place que vous appuyiez sur une autre touche avant de s'arrêter. Dans ce cas, l'événement renvoyé contient `.shift == 1` ou `.alpha == 1` pour indiquer l'état des modifieurs. Une application peut par exemple interpréter un événement ayant `.key == KEY_SIN` et `.shift == 1` comme asin. Activés dans `getkey()`.
• `GETKEY_BACKLIGHT` active la combinaison SHIFT+OPTN pour allumer et éteindre le rétroéclairage sur les modèles monochromes qui le supportent (essentiellement la Graph 75+E et ses prédécesseurs). Activé dans `getkey()`.
• `GETKEY_MENU` autorise le retour au menu en appuyant sur la touche `MENU`. Activé dans `getkey()`.
• `GETKEY_REP_ARROWS` et `GETKEY_REP_ALL` activent la répétition des touches directionnelles et de toutes les touches, respectivement. Le délai de répétition est contrôlé par la fonction `getkey_repeat()`. Par défaut, la première répétition se produit après 400 ms et les suivantes toutes les 40 ms. Le premier est activé dans `getkey()`, pas le second.
• `GETKEY_REP_FILTER` active le filtre de répétitions, un outil puissant qui vous permet de contrôler la répétition des touches avec finesse. Vous pouvez contrôler quelles touches se répétent, combien de fois, sous quel délai, et même changer les délais d'une fois sur l'autre. Activé dans `getkey()`, mais n'a aucun effet tant que vous n'appelez pas `getkey_repeat_filter()` pour configurer tout ça. J'aurai peut-être l'occasion d'en reparler.
Le premier argument de `getkey_opt()` est une combinaison de ces options (qu'on peut écrire avec `+` ou `|` selon votre préférence). Le second argument que j'ai appelé « timeout » est un pointeur sur un entier : si l'entier est ou devient autre chose que 0 pendant que `getkey_opt()` attend, la fonction s'interrompt et s'arrête immédiatement en renvoyant un événement de type `KEYEV_NONE`. Ça permet de contrôler la durée d'attente voire même d'interrompre à l'improviste.
La fonction `getkey()` en elle-même ne fait qu'appeler `getkey_opt()` avec les options que j'ai mentionnées ci-dessus (la combinaison s'appelle `GETKEY_DEFAULT`) et un délai illimité.
Dans `get_inputs()`, on donne comme options `GETKEY_DEFAULT & ~GETKEY_REP_ARROWS`, ce qui reprend les options par défaut de `getkey()` à l'exception de `GETKEY_REP_ARROWS`, et garde un délai illimité. En deux mots : on désactive la répétition des touches fléchées. ^^
Le reste est direct, comme annoncé on cherche les touches directionnelles et on renvoie les `DIR_*` correspondants. Voyons voir maintenant comment implémenter la logique du jeu avec tout ça !
[brown][b]Logique du jeu avec les déplacements[/b][/brown]
Vous allez voir qu'en fait c'est plus court que ça en a l'air.
[code]int main(void)
{
int level = main_menu();
struct player singleplayer = {
.x = 2,
.y = 3
};
struct map map = {
.w = 13,
.h = 7
};
struct game game = {
.map = &map,
.players = { &singleplayer, NULL }
};
int level_finished = 0;
while(!level_finished)
{
int turn_finished = 0;
while(!turn_finished)
{
engine_draw(&game);
dupdate();
int dir = get_inputs();
turn_finished = engine_move(&game, &singleplayer, dir);
}
/* Update doors, etc */
}
return 1;
}[/code]
On commence par appeler le menu principal que j'ai déplacé dans une fonction `main_menu()`, et ensuite on prépare la partie. Pour cela, on crée :
• Un joueur, dont la position initiale est (2,3) ;
• Une map de taille 13x7 (la taille de l'écran présenté précédemment) ;
• Une partie sur ladite map avec ledit joueur.
Ensuite, la boucle principale tourne tant que le niveau n'est pas terminé. À l'intérieur de la boucle, on fait le lien entre les trois parties qu'on a développées jusqu'à présent : d'abord on affiche l'état du jeu, ensuite on prend des entrées, et enfin on déplace le joueur selon les entrées saisies.
Le jeu est au tour par tour et les portes changent d'état entre les tours (action symbolisée par le commentaire "Update doors, etc"), donc j'ai besoin de savoir précisément quand un tour se termine. Ce n'est pas évident car si le joueur demande une direction dans laquelle il/elle ne peut pas se déplacer, le tour ne sera pas fini (et s'il y a plusieurs joueurs la condition n'est pas non plus évidente). Donc j'ai une variable `turn_finished` pour contrôler ça.
Vous noterez que `level_finished` n'est jamais mis à 1 donc la boucle ne se termine jamais ; ce n'est pas grave parce qu'on peut toujours fuir vers le menu principal durant les appels à `getkey_opt()` en appuyant sur MENU. ^^
Et voilà le résultat ! Le code à cette étape est celui du commit [url=https://gitea.planet-casio.com/Lephenixnoir/mystnb/commit/8dba2daeb3c9321d8eb284df0b9076e18addf0c4]`8dba2daeb`[/url] dans l'historique du dépôt.
[center][img]https://gitea.planet-casio.com/Lephenixnoir/mystnb/raw/branch/master/tutorials/gint-tuto-02-basic-movement.gif[/img]
[i]Image 6 : ... ouais en fait non, c'est tout nul.[/i][/center]
Vous comprenez pourquoi j'ai pas mis ça tout en haut du tutoriel. Allez, on va rajouter des animations, ça aidera. ^^"
[brown][b]Structure d'une boucle de jeu animée[/b][/brown]
C'est un peu plus compliqué que ça en a l'air à faire proprement, donc cette fois-ci je ne vais pas détailler tout le code qui me permet de savoir quel sprite afficher où (ce qui n'est pas très intéressant), je vais seulement donner les grandes idées. Ça me permettra de me concentrer sur l'adaptation de la boucle principale du jeu et l'utilisation des timers de gint. ^^
Pour rester simple, je vais fixer la fréquence de l'affichage à un frame toutes les 25 ms. On va bien sûr calculer la vitesse des animations et du jeu en unités de temps réel (c'est-à-dire en millisecondes et non en nombre de frames) donc on pourra toujours changer la fréquence plus tard ; c'est juste une simplification pour l'instant. Je définis le délai comme une macro dans `engine.h`.
[code]/* Time per engine tick (ms) */
#define ENGINE_TICK 25[/code]
L'idée va être de modifier notre boucle principale pour faire exactement un tour toutes les 25 ms. Chaque tour devra donc afficher l'écran, lire le clavier, déplacer le joueur si un ordre a été donné depuis le frame précédent, et mettre à jour les animations. La nouveauté ici c'est la lecture du clavier non-bloquante : dans le code qu'on vient d'écrire, `getkey_opt()` attend que le joueur appuie sur une touche même si ça lui prend 10 secondes. Ici, on va utiliser la fonctionnalité de timeout pour supprimer l'attente car on sait qu'on reviendra tester 25 ms plus tard. Vous allez voir que contrairement à la fonction `GetKey()` de fxlib, `getkey_opt()` peut être utilisée sans problème même en temps réel !
Pour pouvoir obtenir et maintenir ce framerate proprement, on a besoin de deux choses :
• D'abord il nous faut un [i]timer[/i] (une horloge matérielle) pour mesurer les 25 ms et nous avertir quand il faut générer un nouveau frame.
• Ensuite il faut qu'on pense à [i]dormir[/i] entre les frames pour ne pas épuiser les piles. Vous pouvez voir ça comme une sorte de mode veille du processeur.
C'est important de comprendre dès maintenant que l'add-in passe et va continuer à passer la plupart de son temps à dormir. Pour l'instant, il dort pendant `getkey_opt()` et se fait réveiller quand des touches sont pressées. Dans le code qu'on va écrire tout de suite, il va dormir entre les frames et se fera réveiller toutes les 25 ms par le timer pour générer un nouveau frame avant de repartir se coucher aussi sec. Tout ça c'est parce que notre jeu met bien moins de 25 ms à générer chaque frame, donc on a pas mal de temps à tuer. Et si on ne veut pas détruire la batterie de la calculatrice, il est hors de question de juste tourner en rond dans une boucle `while` !
[brown][b]Configuration et utilisation d'un timer[/b][/brown]
Voyons voir comment tout cela marche en pratique. D'abord il nous faut l'en-tête `<gint/timer.h>` pour avoir les timers, et l'en-tête `<gint/clock.h>` pour avoir la fonction `sleep()` qui passe le processeur en veille.
[code]#include <gint/timer.h>
#include <gint/clock.h>[/code]
La première étape de ce nouveau système consiste à choisir et paramétrer un timer. Cela se fait avec la fonction `timer_configure()` qui prend 3 paramètres : le timer à utiliser, le délai d'attente en microsecondes (oui c'est précis !), et une fonction à appeler lorsque le délai sera écoulé.
Prenons tout ça dans l'ordre. Le choix du timer n'est pas totalement évident, car il y a deux genres de timers sur la calculatrice :
• 3 TMU qui ont une résolution très élevée et peuvent compter en-dessous d'une microseconde.
• Entre 1 et 6 ETMU qui ont une résolution plus faible et comptent à 32768 Hz.
Chaque timer est identifié par un entier entre 0 et 8. Il faut qu'on en choisisse un, mais entre ceux qui ne sont peut-être pas assez rapides et ceux qui sont déjà utilisés par gint, c'est un peu casse-tête. On va donc sauter cette étape et demander à gint d'en choisir un pour nous en spécifiant la valeur spéciale `TIMER_ANY` au lieu de donner un entier entre 0 et 8. gint s'arrangera pour trouver un timer libre suffisamment rapide pour mesurer notre délai (ce qui sera facile : tous le sont).
Ensuite, le délai en microsecondes. Ça c'est facile, c'est `ENGINE_TICK*1000` soit 25000.
Dernier argument : une fonction à appeler lorsque le délai arrive à expiration. Cette fonction est souvent appelée [i]callback[/i], et va nous servir à noter dans une variable qu'il faut générer un nouveau frame. Dans gint, elle décide aussi si le timer doit continue de compter (et rappeler le callback une fois le délai écoulé de nouveau) ou s'arrêter ; dans notre cas, on le laissera tourner à l'infini et on l'arrêtera seulement à la sortie de la boucle principale lorsque la partie sera terminée.
Voici à quoi ressemble le callback. Je prends un argument de type `volatile int *`, c'est un pointeur sur un entier. Utiliser ce pointeur permet de modifier un entier ailleurs dans le programme ; dans notre cas ce sera une variable de la fonction `main()`. Je reviendrai sur le `volatile` un peu plus tard. Dans la fonction, j'affecte la variable à 1 à travers le pointeur, puis je renvoie la valeur `TIMER_CONTINUE` qui dit à gint de laisser le timer continuer à compter. L'autre option serait de renvoyer `TIMER_STOP` pour arrêter le timer immédiatement.
[code]static int callback_tick(volatile int *tick)
{
*tick = 1;
return TIMER_CONTINUE;
}[/code]
Pour faire simple, ce callback affecte une variable à 1 et relance le timer. La variable concernée est celle dont on donnera un pointeur en argument quand on va former le callback. Voici l'appel complet.
[code]/* Global tick clock */
static volatile int tick = 1;
int t = timer_configure(TIMER_ANY, ENGINE_TICK*1000, GINT_CALL(callback_tick, &tick));[/code]
La macro `GINT_CALL()` permet de créer un « appel indirect » : on indique quelle fonction on voudra appeler et avec quels paramètres, mais on ne l'appelle pas toute de suite. C'est quelque chose que le langage C n'a pas vraiment prévu et donc il y a des limitations (maximum 4 arguments et pas n'importe quel types), mais ce n'est pas la peine de s'y attarder pour l'instant.
Si cette histoire de callback vous semble un peu compliquée, vous pouvez lire cette à appel à `timer_configure()` de la façon suivante : [i]« en utilisant un timer quelconque (`TIMER_ANY`), toutes les `ENGINE_TICK*1000` microsecondes, gint va appeler `callback_tick(&tick)` »[/i].
Si on récapitule tout, cela veut dire que toutes les 25 millisecondes notre entier `tick` passe à 1. Et donc, si on met la valeur à 0 après avoir généré un frame, on peut dormir en attendant que la variable repasse à 1 et ça nous donnera un frame toutes les 25 ms ! :)
Le `volatile` est important dans cette histoire à cause des optimisations du compilateur. Le compilateur ne sait pas trop ce qu'est un timer et ne vas pas se douter que votre `sleep()` va se faire interrompre régulièrement pour changer la valeur de la variable `tick` (ce qui est bien normal). Lui il voit surtout qu'on affecte `tick` à 0 mais jamais à 1, donc il risque de transformer votre attente en boucle infinie. En ajoutant `volatile`, on affirme que la valeur change même si c'est invisible pour lui, ce qui évite l'optimisation et donc un bug difficile à détecter.
Avec tout ça, on a presque fini. `timer_configure()` nous renvoie le numéro du timer que gint a choisi pour nous (vous savez, celui qu'on avait la flemme de choisir nous-mêmes), ou un nombre négatif si par malheur il n'y a aucun timer disponible qui corresponde à nos besoins. En supposant que tout se passe bien, on peut démarrer notre timer avec `timer_start()`. gint ne le fait pas tout seul car il y a des cas (comme le moteur de gris) où on veut réserver et paramétrer un timer sans le démarrer tout de suite.
[code]if(t >= 0) timer_start(t);[/code]
Toutes les fonctions manipulant des timers, à part `timer_configure()`, prennent en premier argument le numéro du timer concerné. Notez que je protège `timer_start()` au cas où gint n'ait trouvé aucun timer, mais c'est purement par principe : d'une part on sait qu'il y en a forcément un disponible (gint n'en utilise que 2 sur 9), d'autre part si on n'obtient pas de timer le jeu va freezer très rapidement puisqu'il n'y aura rien pour réveiller le programme une fois qu'on se sera endormis. Je protège juste l'appel pour éviter un crash dans cette situation hypothétique.
Ensuite on aura la boucle principale, et après ça on pourra arrêter le timer avant de quitter la fonction `main()`. Pensez bien à libérer vos timers car c'est cette action qui les rend de nouveau disponibles du point de vue de `timer_configure()`. (Pour arrêter un timer sans le libérer, utilisez `timer_pause()`.)
[code]if(t >= 0) timer_stop(t);
return 1;[/code]
Notez que si le callback renvoie `TIMER_STOP` lors d'une interruption, gint arrête immédiatement le timer avec `timer_stop()` donc vous n'avez pas à le faire vous-même (et ne devez pas essayer puisqu'il a pu être de nouveau configuré entre temps).
[brown][b]Boucle de jeu principale animée[/b][/brown]
En mettant bout à bout tout ce qu'on a raconté jusqu'ici, voici la nouvelle forme de la boucle principale du jeu juste après le lancement du timer.
[code]int level_finished = 0;
while(!level_finished)
{
while(!tick) sleep();
tick = 0;
engine_draw(&game);
dupdate();
int dir = get_inputs();
int turn_finished = 0;
if(dir >= 0)
{
turn_finished = engine_move(&game, &singleplayer, dir);
}
if(turn_finished)
{
/* Update doors, etc */
}
engine_tick(&game, ENGINE_TICK);
}[/code]
Les grandes idées ici n'ont pas changé. Avant chaque frame, on commence par dormir jusqu'à ce que `tick` passe à 1. On a besoin de faire une boucle car il y a plein d'interruptions dans la calculatrice, pas que la nôtre, donc on ne peut pas juste `sleep()` une fois et penser que 25 ms se seront écoulées à la première interruption ! Immédiatement ensuite, on met `tick` à 0 pour indiquer qu'on est sur le coup, et on commence à dessiner.
La nouveauté dont j'ai parlé, c'est que cette fois-ci la boucle doit à tout prix se terminer en 25 ms, et donc on ne veut pas attendre que l'utilisateur appuie sur une touche, on veut seulement savoir s'il l'a fait depuis le frame précédent. J'ai donc modifié la fonction `get_inputs()` pour renvoyer un nombre négatif sans attendre si aucune pression de touche n'attendait d'être traitée.
[code]/* Returns a direction to move in */
static int get_inputs(void)
{
int opt = GETKEY_DEFAULT & ~GETKEY_REP_ARROWS;
int timeout = 1;
while(1)
{
key_event_t ev = getkey_opt(opt, &timeout);
if(ev.type == KEYEV_NONE) return -1;
int key = ev.key;
if(key == KEY_DOWN) return DIR_DOWN;
if(key == KEY_RIGHT) return DIR_RIGHT;
if(key == KEY_UP) return DIR_UP;
if(key == KEY_LEFT) return DIR_LEFT;
}
}[/code]
Le changement majeur ici est le deuxième argument à `getkey_opt()`, celui qui permet d'interrompre l'attente. Avant de se mettre en attente, `getkey_opt()` vérifie ce deuxième argument et s'arrête si la valeur est autre chose que 0. Ici, comme on l'a carrément initialisé à 1, `getkey_opt()` n'attendra pas du tout et s'arrêtera immédiatement s'il n'y a aucun événement clavier à traiter. Cette simple modification en fait une sorte de `Getkey` du Basic Casio mais en beaucoup plus puissant. ;)
Si jamais `getkey_opt()` se fait interrompre, elle renvoie un événement dont le type est `KEYEV_NONE` (alors que tous les événements qu'on avait vus jusqu'à présent avaient le type `KEYEV_DOWN` indiquant qu'une touche a été pressée). Comme j'ai besoin de regarder le type et la touche à la fois, je stocke l'événement dans une variable, ce qui est une bonne occasion de donner quelques détails.
Comme vous pouvez le voir, le type d'un événement clavier est `key_event_t` (comme d'habitude le "`_t`" à la fin est une convention signifiant que c'est un type pour ne pas le confondre avec un nom de variable). Il est défini dans `<gint/keyboard.h>` et possède les champs suivants :
• `type` : le type d'événement, qui peut être `KEYEV_NONE` (rien), `KEYEV_DOWN` (pression), `KEYEV_UP` (relâchement) ou `KEYEV_HOLD` (répétition). Toutes les fonctions ne génèrent pas tous les types d'événements, par exemple `getkey_opt()` ne renvoie jamais un événement de type `KEYEV_UP`. De même, aucune autre fonction que `getkey()` et `getkey_opt()` ne renvoie d'événements de type `KEYEV_HOLD`.
• `key` : la touche qui a été pressée. La liste complète est dans `<gint/keycodes.h>`.
• `mod`, `shift` et `alpha` : l'état des modifieurs. Si `mod` vaut 1 alors `shift` et `alpha` indiquent si les touches SHIFT et ALPHA ont été pressées pour modifier la touche ; `getkey()` et `getkey_opt()` font ça. Si `mod` vaut 0 alors les modifieurs sont ignorés, c'est ce qui se passe dans toutes les autres fonctions.
• `time` : le moment où l'événement s'est produit. Très pratique pour analyser des séquences de touches comme les combos, car la précision (128 Hz par défaut) est bien plus élevée que le framerate de la plupart des jeux (surtout sur la Graph 90+E), et ça coûte bien moins cher d'accélérer les analyses du clavier que d'accélérer les updates du jeu.
Je ne vais pas parler en détail des autres fonctions de lecture du clavier, voici simplement de quoi vous donner une vue d'ensemble. Le driver clavier de gint génère des événements de type `KEYEV_DOWN` et `KEYEV_UP` à intervalles réguliers. Vous pouvez utiliser `pollevent()` et `waitevent()` pour lire ces évènements-là, ou bien vous pouvez utiliser `getkey()` et `getkey_opt()` pour les combiner entre eux, ce qui permet principalement d'utiliser les modifieurs SHIFT et ALPHA, les combinaisons comme MENU et le rétroéclairage, et de générer des répétitions de touches. C'est conçu pour que vous puissiez passer de l'un à l'autre sans problème.
Enfin, comme je sais que vous allez essayer de vous en servir malgré le fait qu'elle est moins utile, vous avez `keydown()` qui vous dit si une touche est pressée [i]d'après les événements que vous avez lus[/i]. C'est important, `keydown()` ne vous dit pas si une touche est pressée au moment exact de l'appel, elle vous dit si un événement de type `KEYEV_DOWN` a été lu pour cette touche et qu'aucun événement de type `KEYEV_UP` correspondant n'a été lu depuis. Utilisez `clearevents()` au début de votre frame pour lire tous les événements et donc les synchroniser avec l'état du clavier. Si ça vous paraît bizarre, sachez que c'est crucial pour faire interagir proprement `keydown()` avec les autres fonctions... et que c'est supérieur sur pas mal d'aspects. J'en reparlerai je pense. ^^
Le reste de la nouvelle boucle principale devrait être sensiblement intuitif, notez surtout qu'on ne se déplace pas à chaque tour et que le tour courant ne se termine pas non plus à chaque tour. La fonction `engine_tick()` est utilisée pour faire avancer les animations du délai spécifié.
[brown][b]Animations structurées[/b][/brown]
Pour conclure ce tutoriel, voici une présentation rapide du système d'animations que j'ai implémenté, principalement dans deux nouveaux fichiers `animation.h` et `animation.c`. Comme précédemment, je commence par les structures de données ; une est importante ici.
[code]/* struct anim_data: Data for currently-running animations */
struct anim_data
{
/* Animation update function */
anim_function_t *function;
/* Frame to draw */
struct anim_frame img;
/* On-screen entity displacement */
int dx, dy;
/* Animation direction */
int dir;
/* Current frame */
int frame;
/* Duration left until next frame; updated by the engine. Animation
function is called when it becomes negative or null */
int duration;
};[/code]
La structure `anim_data` décrit l'état d'une animation en cours d'exécution. Le premier attribut est un pointeur sur une fonction à appeler pour déterminer le frame d'animation suivant (qui n'est pas forcément un frame du jeu ; il peut durer bien plus que 25 ms). Le deuxième attribut représente un sous-rectangle d'une image et liste juste les paramètres à passer à `dsubimage()` pour dessiner le sprite courant.
Ensuite, on a un déplacement appliqué au joueur lors du dessin ; c'est utilisé pour faire avancer progressivement le joueur d'une case vers une autre même si sa position absolue est en cases, et je m'en servirai aussi pour animer les clés (... des portes). Puis la direction de l'animation pour les objets qui ont une direction, le frame actuel et la durée restante.
Le travail de `engine_tick()` consiste simplement à réduire `duration` du temps écoulé depuis le dernier frame (à savoir 25 ms dans notre cas) et à appeler la fonction pour déterminer le frame suivant si le temps restant atteint 0.
[code]void engine_tick(struct game *game, int dt)
{
/* Update the animations for every player */
for(int p = 0; game->players[p]; p++)
{
struct player *player = game->players[p];
player->anim.duration -= dt;
if(player->anim.duration > 0) continue;
/* Call the animation function to generate the next frame */
player->idle = !player->anim.function(&player->anim, 0);
}
}[/code]
La fonction d'animation en elle-même s'occupe principalement de passer au sprite suivant et de recharger `duration`, et renvoie un entier pour indiquer si le joueur est « occupé » : c'est utilisé pour refuser le mouvement durant une animation de marche. L'animation de marche change aussi les valeurs de `dx` et `dy` pour déplacer visuellement le joueur au cours du temps, et effectue une transition vers l'animation par défaut lorsque le joueur arrive à sa destination.
Le code de cette nouvelle version du programme se trouve au commit [url=https://gitea.planet-casio.com/Lephenixnoir/mystnb/commit/30ab7bae0a6746365c86c1ca81b9aa1ea98a4775]`30ab7bae0`[/url] sur le dépôt. Voyez notamment [url=https://gitea.planet-casio.com/Lephenixnoir/mystnb/src/commit/30ab7bae0a6746365c86c1ca81b9aa1ea98a4775/src/animation.c]`animation.c`[/url] si le code détaillé des animations vous intéresse.
Il est temps de regarder ce que tout ça nous donne sur la calculatrice... !
[center][img]https://gitea.planet-casio.com/Lephenixnoir/mystnb/raw/branch/master/tutorials/gint-tuto-02-animated-movement.gif[/img]
[i]Image 7 : Toujours pas de gameplay, mais fichtre c'est beau.[/i][/center]
Magnifique ! :D
[brown][b]Conclusion[/b][/brown]
Ce tutoriel introduit la plupart des notions du noyau nécessaires pour coder des jeux en temps réel. Il y a aussi des aspects inhérents au fxSDK comme la conversion des maps, et quelques subtilités comme l'utilisation du système de fichiers. Mais dans l'ensemble, le plus dur est de structurer votre code d'une façon qui résistera aux multiples itérations que vous ferez sur vos programmes. ^^
Petit résumé de ce qu'on a vu ici !
• Utilisation de `dsubimage()` pour former une spritesheet
• `<gint/keyboard.h>` : `getkey_opt()` et ses options
• Cas simple d'utilisation d'un timer
• `<gint/timer.h>` : `timer_setup()`, `timer_start()`, `timer_stop()`
• `<gint/clock.h>` : `sleep()`
• Les événements claviers et leurs types
• Description rapide de `pollevent()`, `waitevent()`, `clearevents()` et `keydown()`