tutorial-02: animated player

This commit is contained in:
Lephenixnoir 2020-08-21 16:07:08 +02:00
parent 30ab7bae0a
commit aadaaf4a81
Signed by: Lephenixnoir
GPG Key ID: 1BBA026E13FC0495
3 changed files with 191 additions and 6 deletions

View File

@ -8,7 +8,7 @@ Maintenant qu'on s'est un peu échauffés avec le menu principal, il est temps d
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][/img]
[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. ;)
@ -182,7 +182,7 @@ Rien de bien inattendu ici, juste une utilisation un peu maline de `dsubimage()`
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-mapbounds.png[/img]
[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.
@ -200,9 +200,9 @@ Dans la boucle `for`, la condition de sortie `game->players[p]` est équivalente
[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-mapgrid.png[/img]
[i]Image 5 : Contrairement à Portal, il n'y a pas vraiment de sortie.[/i][/center]
[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)
{
@ -321,12 +321,197 @@ Le jeu est au tour par tour et les portes changent d'état entre les tours (acti
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.
Et voilà le résultat. Le code à cet é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. Quelle variable ? Eh bien 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 lors d'une interruption le callback renvoie `TIMER_STOP`, 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 donc le type est `KEYEV_NONE` (alors que tous les événements qu'on avait vus jusqu'à présent avait le type `KEYEV_DOWN` indiquant qu'une touche a été pressée). Comme j'ai besoin de tester le type et de 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ées. 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 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/b8ae9615b84bd5b50c10138e93af543cf3187a50]`b8ae961`[/url] sur le dépôt. Voyez notamment [url=https://gitea.planet-casio.com/Lephenixnoir/mystnb/src/commit/b8ae9615b84bd5b50c10138e93af543cf3187a50/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

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB