Les membres ayant 30 points peuvent parler sur les canaux annonces, projets et hs du chat.
La shoutbox n'est pas chargée par défaut pour des raisons de performances. Cliquez pour charger.

Forum Casio - Vos tutoriels et astuces


Index du Forum » Vos tutoriels et astuces » Tutoriels d'utilisation de gint
Lephenixnoir En ligne Administrateur Points: 24706 Défis: 170 Message

Tutoriels d'utilisation de gint

Posté le 15/07/2017 13:53

Pas de commentaires sur ce topic ! Merci de poster par ici.

Ce topic fait partie de la série de topics du fxSDK.

Bien le salut, développeur ! Cette série de tutoriels présente l'utilisation de gint et du fxSDK pour écrire des add-ins.

Pour reproduire les manips' réalisées ici, il te faut au préalable avoir installé :

Un cross-compilateur pour la calculatrice ;
Le fxSDK ;
Le noyau gint (important ).

Plutôt que de vous embêter avec un catalogue de fonctions qui ne serait amusant ni pour vous ni pour moi, je vous propose d'utiliser gint pour monter un petit jeu de puzzle en développant au fur et à mesure le code et les ressources. C'est pas un tutoriel de pixel art donc je vous parachuterai tout ce qui s'éloigne du code, mais c'est l'idée.

Il n'est pas question de donner tout le code et toutes les commandes shell (ce serait beaucoup trop indigeste), mais de s'arrêter sur le développement de différents points-clés intéressants, prétextes pour utiliser les fonctionnalités du fxSDK et pour parler un peu de game design. Je penserai à mettre le code sur un dépôt Git.

Enfin, pour garder les différents posts (qui seront peut-être un peu longs) ensembles et dans l'ordre sur ce topic, je vous demanderai de ne pas commenter ici mais sur le topic suivant :

Tutoriels d'utilisation de gint (commentaires)

Sur ce, il est temps de passer aux choses sérieuses. Bonne lecture !

Tutoriels existant à ce jour (en cours)

01 : Du mystère au menu !
02 : Un peu de logique temporelle !
03 : Des portes... beaucoup de portes.


Lephenixnoir En ligne Administrateur Points: 24706 Défis: 170 Message

Citer : Posté le 21/08/2020 17:02 | #


01 : Du mystère au menu !

Je vous propose de commencer cette série par l'écran d'accueil du jeu et un peu de gestion de projet. Normalement il faudrait coder le moteur d'abord, mais ce serait trop brutal. Voilà ce qu'on va réaliser à la place :


Image 1 : la pub ne mentait pas sur le « noir et blanc ».

Une police facile à dessiner, un titre qui en promet plus que le jeu n'en propose, et un joli effet graphique pour vous surprendre un peu, rien de bien extravagant. J'ai fait sobre dans le menu pour rester dans le minimalisme de la 1kBCJ#3 dont ce jeu est issu, mais vous allez voir que ce menu tout simple va déjà nous occuper un bon moment.

On a donc une image fixe et une sélection de niveaux tout ce qu'il y a de plus classique. Le but c'est d'avoir un fichier de sauvegarde qui dit quels niveaux on a débloqués, mais ça ce sera pas pour tout de suite, donc pour l'instant on va automatiquement débloquer tous les niveaux sauf le dernier pendant les tests.

Avant de commencer, notez que tout ce tutoriel est suivi sur un dépôt Git. Si vous ne voulez pas vous lancer dans un projet de zéro tout de suite, vous pouvez clôner celui du jeu et vous déplacer dans l'historique tout en suivant le tutoriel. Dans tous les cas, vous y trouverez les images et ressources qu'on va utiliser tout le long ; je donnerai donc régulièrement des liens qui y pointent.

Création du projet

Commençons par créer les fichiers dont on a besoin. Le fxSDK fournit un modèle de projet avec tout ce qu'il faut pour compiler, via l'outil fxsdk. Le projet sera compilé avec CMake ; vous n'êtes pas obligés de comprendre les fins détails mais je vous invite à lire le tutoriel de compilation d'add-ins avec CMake qui explore cet aspect en détail. Pour l'instant, mettez-vous dans un dossier de votre choix (pour moi ~/Programs) et créez un nouveau projet :

% fxsdk new mystere-noir-et-blanc
Created a new project Mystere- (build system: CMake).
Type 'fxsdk build-fx' or 'fxsdk build-cg' to compile the program.

Le fxSDK a créé l'add-in automatiquement. Voyons voir ce qu'il y a dans ce dossier pour commencer ! Vous pouvez le parcourir interactivement sur Gitea en consultant le commit fd94704d0.

(Note : Ce tutoriel a été commencé en 2020, à une époque où le fxSDK produisait un projet avec un Makefile. J'ai ensuite réécrit l'historique en 2021 quand CMake a été ajouté. Si vous voulez la version Makefile du début de ce tutoriel, consultez la branche makefile.)

% tree mystere-noir-et-blanc
mystere-noir-et-blanc
├── assets-cg
│   ├── example.png
│   ├── fxconv-metadata.txt
│   ├── icon-sel.png
│   └── icon-uns.png
├── assets-fx
│   ├── example.png
│   ├── fxconv-metadata.txt
│   └── icon.png
├── CMakeLists.txt
└── src
    └── main.c

Le fxSDK a créé un certain nombre de dossiers et fichiers. Voici à quoi ils servent :

assets-cg contient toutes les images, polices et autres ressources pour la Graph 90+E. Le fxSDK permet de programmer pour la Graph 90+E, et même de faire un add-in pour les Graph mono et la Graph 90+E en même temps. Ici, on va programmer uniquement sur Graph mono, donc je vais l'ignorer.
assets-fx contient les images, polices et autres ressources pour les Graph mono. C'est là qu'on va mettre la plupart de nos données ! Toutes les Graph mono sont identiques du point de vue de gint, de la Graph 75+E que j'utilise aux Graph 35+ USB, 35+E, 35+E II, et même les vieilles SH3, tout est compatible.
• Le CMakeLists.txt est un fichier indiquant comment compiler l'application. Quand vous tapez "fxsdk build-fx" dans le terminal, c'est lui qui donne toutes les instructions.
src contient comme d'habitude tous les fichiers de code. Le fxSDK a copié un main.c avec un code d'exemple.

Je ne vais pas beaucoup utiliser le CMakeLists.txt dans ce tutoriel, mais on va prendre un instant pour y indiquer des noms plus appropriés pour l'add-in et le fichier g1a que ce que le fxSDK a mis par défaut. Dans le fichier, cherchez l'appel à generate_g1a(), qui ressemble à ça :

  generate_g1a(TARGET myaddin OUTPUT "MyAddin.g1a"
    NAME "MyAddin" ICON assets-fx/icon.png)

Le paramètre NAME est le nom de l'add-in dans le menu SYSTEM, et le paramètre OUTPUT est le nom du fichier g1a. On va remplacer "MyAddin" par MystNB :

  generate_g1a(TARGET myaddin OUTPUT "MystNB.g1a"
    NAME "MystNB" ICON assets-fx/icon.png)

Et voilà, on peut attaquer le code.

Hello, World!

C'est parti ! Commençons avec le "Hello, World!" de gint que le fxSDK a copié dans src/main.c pour vous.

#include <gint/display.h>
#include <gint/keyboard.h>

int main(void)
{
    dclear(C_WHITE);
    dtext(1, 1, C_BLACK, "Sample fxSDK add-in.");
    dupdate();

    getkey();
    return 1;
}

Pour le réaliser, on a besoin d'utiliser des fonctions de gint qui sont décrites par deux en-têtes, <gint/display.h> (le partie dessin et l'affichage) et <gint/keyboard.h> (la gestion du clavier). Lorsque vous les incluez, ces en-têtes expliquent au compilateur quelles sont les fonctions proposées par gint, mais aussi quels sont les noms des couleurs et comment reconnaître les images et les polices. Si vous essayez d'utiliser les fonctionnalités de gint sans avoir inclus les en-têtes correspondants, le compilateur se plaindra qu'il ne sait pas de quoi vous parlez.

Et donc une fois les en-têtes inclus on commence tout de suite à sortir les pinceaux.

dclear(C_WHITE) efface la VRAM et remplit tout en blanc. C'est comme Bisp_AllClr_VRAM() excepté que vous pouvez changer de couleur. Les couleurs sont définies dans <gint/display-fx.h> et <gint/display-cg.h>, j'en reparle dans un instant.
dtext(x,y,fg,str) affiche la chaîne de caractères str à la position indiquée et avec la couleur spécifiée. La position est en pixels, avec (0,0) en haut à gauche. Ce sera le cas pour toutes les fonctions de dessin, sans exception aucune ! fg est la couleur du texte. Cette fonction ressemble à PrintXY(), sauf qu'on a plus de choix de couleurs et que plus tard on pourra changer la couleur de fond, l'alignment du texte et la police !
dupdate() affiche les contenus de la VRAM à l'écran, c'est l'équivalent de Bdisp_PutDisp_DD() tant qu'on n'active pas le moteur de gris.

En plus des informations importantes pour le compilateur, les en-têtes de gint contiennent aussi des informations importantes pour vous, avec la liste des fonctions, leurs paramètres et leurs rôles. Je vous conseille de prendre l'habitude d'aller les lire si vous avez besoin d'informations. Par exemple, la liste des couleurs est dans les en-têtes <gint/display-fx.h> et <gint/display-cg.h>. (Comme le dessin est très différent entre Graph mono et Graph 90+E, <gint/display.h> est séparé en deux versions.) Tous ces en-têtes sont dans le dossier include de gint. Vous pouvez les trouver dans le dossier où vous avez clôné gint lors de l'installation, ou en ligne sur le dépôt Gitea.

Vous savez certainement que les add-ins s'exécutent vite, c'est sans doute pour ça que vous êtes ici. Dans ce programme, l'affichage va prendre entre 1 et 2 ms. (Sur Graph 90+E, il faudrait compter entre 15 et 20 ms, tout simplement parce qu'il y a 170 fois plus de donnés graphiques à manipuler.) On ne veut pas que l'add-in s'arrête maintenant, parce que contrairement à un programme Basic il reviendrait directement au menu de la calculatrice et on ne verrait rien.

On utilise pour éviter ça la fonction getkey(), qui met le programme en pause jusqu'à ce que l'utilisateur appuie sur une touche. getkey() renvoie un « événement » indiquant quelle touche a été pressée, quand, et d'autres informations utiles. Chaque touche a un nom, que vous pouvez trouver dans <gint/keycodes.h> (qui est inclu par <gint/keyboard.h>, c'est pour ça que je ne vous l'ai pas fait inclure tout à l'heure).

La fonction getkey() est centrale, vous l'utiliserez pour toutes les entrées clavier sur tous les écrans qui ne sont pas en temps réel (comme les menus ou les applications utilitaires), et parfois même pour les écrans en temps réel dans les jeux. J'aimerais donc éclaircir trois choses au sujet de getkey() pour éviter toute confusion avec la fonction GetKey() que l'on trouve dans fxlib et que vous avez peut-être déjà utilisée.

1. getkey() attend. Le code qui suit (le return) ne sera pas exécuté tant que l'utilisateur n'aura pas appuyé sur une touche, peu importe si ça lui prend 20 minutes !
2. getkey() renvoie un événement, alors que GetKey() modifie un pointeur qu'on lui passe en argument.
3. getkey() ne rafraîchit pas l'écran, contrairement à GetKey() qui appelle Bdisp_PutDisp_DD() avant de se mettre en attente. Il faut appeler dupdate() explicitement.

Pour l'instant on ignore complètement la valeur de retour de getkey() (l'événement qui nous dit, entre autres, quelle touche a été pressée) donc on peut appuyer sur n'importe quelle touche pour quitter. On changera ça bientôt !

Compiler et tester

La compilation d'un add-in mérite des tutoriels complets ; vous pouvez découvrir les grands principes dans le Tutoriel du Mercredi #16 et les spécificités de CMake dans le tutoriel de compilation d'add-ins avec CMake. Pour l'instant, on va ignorer tous ces détails et utiliser le système que le fxSDK a copié pour nous. Pour compiler votre application pour la famille des Graph mono (Graph 35+E et affiliées), utilisez la commande "fxsdk build-fx". (Dans le fxSDK, "fx" représente la famille des Graph mono, tandis que "cg" représente les Prizm et Graph 90+E. Naturellement il existe aussi "fxsdk build-cg" pour compiler une version Graph 90+E.)

% fxsdk build-fx
-- The C compiler identification is GNU 10.2.0
-- The CXX compiler identification is GNU 10.2.0
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /home/el/.local/bin/sh-elf-gcc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /home/el/.local/bin/sh-elf-g++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Found Gint: TRUE (found suitable version "2.2.1-6", minimum required is "2.1")
-- Configuring done
-- Generating done
-- Build files have been written to: /tmp/mystere-noir-et-blanc/build-fx
Scanning dependencies of target myaddin
[ 33%] Building C object CMakeFiles/myaddin.dir/src/main.c.obj
[ 66%] Building FXCONV object CMakeFiles/myaddin.dir/assets-fx/example.png
[100%] Linking C executable myaddin
[100%] Built target myaddin

Il y a pas mal de détails sordides ici, mais vous devez pouvoir comprendre une partie du texte qui apparaît à l'écran, car c'est ici que vous aurez quasiment tous vos messages d'erreur pendant le développement. Bien comprendre les erreurs et qui vous les a envoyées vous évitera beaucoup de frustration et de temps perdu.

La première étape du processus est la configuration, où CMake analyse le fichier CMakeLists.txt, vérifie que le compilateur marche, que les bibliothèques comme gint sont bien là, et produit un Makefile qui permet de compiler l'application. Toutes les lignes au début qui ont un -- sont des messages affichés par CMake durant la configuration.

La deuxième étape du processus est la compilation, où le Makefile produit par CMake est lancé. Ce Makefile appelle GCC et affiche notamment les messages avec un pourcentage devant. À chacune des trois lignes en vert, make a lancé une commande pour compiler un fichier (les deux "Building ...") ou produire l'add-in final (le "Linking ...").

Par défaut, les commandes ne sont pas affichées, et on n'a que les lignes en vert. Mais si vous tapez fxsdk build-fx -B VERBOSE=1 elles seront affichées. VERBOSE=1 demande au Makefile d'afficher les commandes, et -B force à recompiler même si les sources n'ont pas changé (normalement si rien n'a changé rien n'est recompilé). Si vous faites ça, vous aurez (notamment) les lignes suivantes :

% fxsdk build-fx -B VERBOSE=1
(...)
[ 33%] Building C object CMakeFiles/myaddin.dir/src/main.c.obj
/home/el/.local/bin/sh-elf-gcc -DFX9860G -DTARGET_FX9860G  -mb -ffreestanding -nostdlib -Wa,--dsp -Wall -Wextra -Os -fstrict-volatile-bitfields -o CMakeFiles/myaddin.dir/src/main.c.obj -c /tmp/mystere-noir-et-blanc/src/main.c
[ 66%] Building FXCONV object CMakeFiles/myaddin.dir/assets-fx/example.png
fxconv /tmp/mystere-noir-et-blanc/assets-fx/example.png -o CMakeFiles/myaddin.dir/assets-fx/example.png --toolchain=sh-elf --fx
[100%] Linking C executable myaddin
/usr/bin/cmake -E cmake_link_script CMakeFiles/myaddin.dir/link.txt --verbose=1
/home/el/.local/bin/sh-elf-gcc -nostdlib -T fx9860g.ld CMakeFiles/myaddin.dir/src/main.c.obj CMakeFiles/myaddin.dir/assets-fx/example.png -o myaddin  -lgcc -lgcc /home/el/.local/share/giteapc/Lephenixnoir/sh-elf-gcc/lib/gcc/sh3eb-elf/10.2.0/libgint-fx.a -lgcc
/home/el/.local/bin/sh-elf-objcopy -O binary -R .bss -R .gint_bss myaddin myaddin.bin
fxg1a myaddin.bin -n MyAddin -i /tmp/mystere-noir-et-blanc/assets-fx/icon.png -o /tmp/mystere-noir-et-blanc/MyAddin.g1a
[100%] Built target myaddin
(...)

Chaque ligne (excepté les messages du Makefile en vert) est une commande que le Makefile a lancé et qui contribue à compiler votre add-in. Le premier mot de chaque commande est le nom d'un outil qui a travaillé pour vous, les autres mots sont des options.

La première commande fait appel à sh-elf-gcc : c'est le compilateur. Il compile src/main.c (qui est mentionné tout à la fin de la ligne). La seconde fait appel à fxconv, qui convertit l'image assets-fx/example.png. Les autres commandes utilisent sh-elf-gcc, sh-elf-objcopy et fxg1a pour rassembler tous les résultats et créer un fichier g1a.

C'est pas grave si tout cela vous échappe un peu, j'y reviendrai de temps en temps. Pour l'instant, vous avez deux nouvelles choses dans le dossier de votre projet :

• Un dossier build-fx qui contient tous les fichiers compilés. Vous pouvez le supprimer à tout moment, mais il permet de recompiler l'application plus vite en récupérant le code déjà compilé quand vous ne l'avez pas modifié. Par exemple, si vous retapez fxsdk build-fx il ne se passe rien car vous n'avez rien modifié depuis la dernière compilation. En général vous voulez donc le laisser tranquille. Toutefois il n'est pas apprécié sur un dépôt Git donc ajoutez-le dans votre .gitignore.
• Le fichier MystNB.g1a qui est notre add-in compilé !

Et c'est terminé ! Il ne reste qu'à envoyer l'add-in sur votre calculatrice par votre méthode préférée. Sur ma Graph 35+E II je l'enverrai par USB avec fxlink -sw MystNB.g1a. Pour les autres Graph, un outil de choix sous Linux est l'utilitaire P7 de Cakeisalie5 (que fxsdk send-fx appelle pour vous !). Vous pouvez aussi utiliser FA-124, mais si vous en arrivez là je vous plains. ^^"

Une fois transféré, l'add-in apparaît dans le menu et on peut observer le résultat attendu :


Image 2 : Yeah!


Sur ce, il est temps de passer aux choses sérieuses !

Les assets

Voici les assets que l'on va utiliser : une icône pour l'add-in, l'image du titre, les icônes pour les niveaux, et la police de caractères pour écrire le texte du jeu et les numéros des niveaux. On ne va pas utiliser celle par défaut de gint, qui manque un peu de style. Le PNG est fortement conseillé et même obligatoire pour l'icône.



Image 3 : La clé du mystère était dans l'icône depuis le début !



Image 4 : Aujourd'hui on repousse les limites du lisible.



Image 5 : Il y aura plus que deux niveaux quand même.



Image 6 : Une police qui a du caractère !


Les images ci-dessus sont agrandies, bien sûr pour le projet il vous faut les originaux. Vous pouvez les télécharger directement sur le dépôt :

» Dossier assets-fx sur le dépôt à ce stade «

Comme vous pouvez le voir, la police qu'on utilise est vraiment une image, c'est juste une grille de caractères. Le fxSDK va la convertir en une police utilisable avec dtext() en utilisant un de ses outils, fxconv.

Téléchargez ou copiez les trois images dans assets-fx (celles du dépôt, pas les versions agrandies visibles sur cette page !). Au passage, je vais supprimer le fichier d'exemple example.png. Vous devez obtenir ceci :

% tree assets-fx
assets-fx
├── fxconv-metadata.txt
├── font_mystere.png
├── icon.png
├── levels.png
└── title.png

Pour pouvoir convertir tout cela avec fxconv, il va falloir fournir quelques informations. Le résultat de la conversion sera une variable dans le programme C, il faut donc lui donner un nom. Et on a aussi plein d'informations à fournir sur la police, notamment quels caractères sont dessinés et à quel endroit. Toutes ces métadonnées sont à indiquer dans un fichier fxconv-metadata.txt, et le fxSDK en a créé un pour nous. Voyons ce qu'il contient.

example.png:
  type: bopti-image
  name: img_example

fxconv-metadata.txt contient une liste de blocs (ici 1). Chaque bloc sert à donner des informations sur un ou plusieurs fichiers : on indique d'abord le nom du fichier, et ensuite les paramètres qui s'y appliquent (avec une indentation). Les deux paramètres essentiels sont type, qui indique quelle conversion on veut faire, et name, qui indique le nom de la variable résultante. Ici, on convertit example.png comme une image bopti et on produit une variable img_example en résultat. bopti est un module de gint chargé de l'affichage des images, c'est lui qui fournit la fonction dimage() dont on se servira dans un instant.

On va modifier ça et donner les paramètres pour nos images et pour la police font_mystere.png

levels.png:
  type: bopti-image
  name: img_levels

title.png:
  type: bopti-image
  name: img_title

font_mystere.png:
  type: font
  charset: print
  grid.size: 5x7
  grid.padding: 1
  proportional: true

Pour les images, rien de très nouveau. Pour la police il y a plus d'informations ; voyons ce qu'elles veulent dire.

• "charset:print" indique quels sont les caractères qui sont dessinés. "print" c'est l'ensemble des caractères affichables de l'ASCII, il y en a 95 qui commencent par l'espace et se terminent par le tilde. (Le carré en bas à droite sera ignoré.) Avec cette information, fxconv sait que le 35ème caractère est "B".

• "grid.size:5x7" indique quelle taille fait chaque caractère. Comme vous pouvez le voir, on donne la même taille pour tout le monde, donc il faut indiquer une taille assez grande. La plupart des caractères font moins de 5 pixels de large, mais M, W et quelques autres nous obligent à utiliser une grille de largeur au moins 5. Pour la hauteur, la plupart des caractères font 6 pixels mais il y a des caractères comme g ou la virgule qui descendent une ligne plus bas que les autres, portant le total à 7. Cette grille dit à fxconv où les caractères sont dessinés dans notre PNG, mais ce n'est pas forcément la taille finale à l'écran de la calculatrice.

• "grid.padding:1" indique qu'autour de chaque caractère, j'ai laissé un cadre blanc de 1 pixel de large. Je l'ai fait pour conserver un espacement confortable pendant l'édition. Vous pouvez le voir sur l'image suivante où j'ai mis le padding en valeur en bleu et jaune :



Image 6 : Il y a un pixel de padding autour de chaque caractère.

• "proportional:true" signifie qu'on veut une police à largeur variable : on veut que chaque caractère prenne juste la place qui est nécessaire pour le dessiner sur la calculatrice. Ainsi, même si M ou W prennent 5 pixels, I fera quand même 1 pixel et A 4 pixels. En pratique, après avoir isolé chaque caractère en utilisant la grille, fxconv va encore éliminer le blanc à gauche et à droite.

Vous n'avez pas besoin de comprendre tous les détails de comment les polices marchent pour l'instant. Si des choses vous échappent, vous aurez l'occasion d'y revenir plus tard. Au passage, Shadow15510 a écrit un script pour générer une grille orange/bleue dans ce style.

Avant de pouvoir tester, il faut qu'on pense à modifier la liste des fichiers du projet dans CMakeLists.txt. Ici, j'ai simplement ajouté des assets pour Graph mono, donc je modifie la variable ASSETS_fx. Si vous n'êtes pas à l'aise avec cete partie de CMake, je vous invite à lire le tutoriel d'introduction à CMake qui est très détaillé.

set(ASSETS_fx
  assets-fx/levels.png
  assets-fx/title.png
  assets-fx/spritesheet.png
  assets-fx/font_mystere.png
  # ...
)

Vous pouvez maintenant recompiler avec "fxsdk build-fx" et observer que l'image et la police sont converties !

% fxsdk build-fx
-- Configuring done
-- Generating done
-- Build files have been written to: /home/el/Programs/mystere-noir-et-blanc/build-fx
Scanning dependencies of target myaddin
Consolidate compiler generated dependencies of target myaddin
[ 20%] Building FXCONV object CMakeFiles/myaddin.dir/assets-fx/levels.png
[ 40%] Building FXCONV object CMakeFiles/myaddin.dir/assets-fx/title.png
[ 60%] Building FXCONV object CMakeFiles/myaddin.dir/assets-fx/font_mystere.png
[ 80%] Linking C executable myaddin
[100%] Built target myaddin

Vous pouvez voir qu'il y a de nouveau des lignes avec --. C'est parce qu'on a modifié CMakeLists.txt, du coup CMake reconfigure et recompile au lieu de simplement recompiler. De nouveau, si vous ajoutez VERBOSE=1, vous verrez le détail des commandes (-B là aussi est nécessaire pour forcer à recompiler) :

% fxsdk build-fx -B VERBOSE=1
(...)
[ 40%] Building FXCONV object CMakeFiles/myaddin.dir/assets-fx/levels.png
fxconv /home/el/Programs/mystere-noir-et-blanc/assets-fx/levels.png -o CMakeFiles/myaddin.dir/assets-fx/levels.png --toolchain=sh-elf --fx
[ 60%] Building FXCONV object CMakeFiles/myaddin.dir/assets-fx/title.png
fxconv /home/el/Programs/mystere-noir-et-blanc/assets-fx/title.png -o CMakeFiles/myaddin.dir/assets-fx/title.png --toolchain=sh-elf --fx
[ 80%] Building FXCONV object CMakeFiles/myaddin.dir/assets-fx/font_mystere.png
fxconv /home/el/Programs/mystere-noir-et-blanc/assets-fx/font_mystere.png -o CMakeFiles/myaddin.dir/assets-fx/font_mystere.png --toolchain=sh-elf --fx
[100%] Linking C executable myaddin
/usr/bin/cmake -E cmake_link_script CMakeFiles/myaddin.dir/link.txt --verbose=1
/home/el/.local/bin/sh-elf-gcc -nostdlib -T fx9860g.ld CMakeFiles/myaddin.dir/src/main.c.obj CMakeFiles/myaddin.dir/assets-fx/levels.png CMakeFiles/myaddin.dir/assets-fx/title.png CMakeFiles/myaddin.dir/assets-fx/font_mystere.png -o myaddin  -lgcc /home/el/.local/share/giteapc/Lephenixnoir/sh-elf-gcc/lib/gcc/sh3eb-elf/10.2.0/libgint-fx.a -lopenlibm -lgcc
/home/el/.local/bin/sh-elf-objcopy -O binary -R .bss -R .gint_bss myaddin myaddin.bin
fxg1a -n MystNB -i /home/el/Programs/mystere-noir-et-blanc/assets-fx/icon.png -o /home/el/Programs/mystere-noir-et-blanc/MystNB.g1a myaddin.bin
[100%] Built target myaddin

Reprenons un instant pour voir ce qui s'est passé. fxconv a été appelé deux fois, une pour convertir la police et une pour convertir l'image. Ensuite sh-elf-gcc a été appelé pour fusionner le résultat de la conversion avec le résultat de la compilation de main.c. Enfin, sh-elf-objcopy et fxg1a ont été appelés de nouveau pour recréer le fichier g1a. C'est un peu difficile à lire parce que les commandes sont compliquées, mais voici quelques pistes :

• Les lignes qui commencent par /usr/bin/cmake -E peuvent être ignorées.
• Les noms de fichiers à rallonge comme CMakeFiles/myaddin.dir/src/main.c.obj n'ont pas vraiment d'importance : regardez seulement le dernier morceau, main.c.obj. C'est ce dont vous avez besoin pour comprendre de quel fichier il s'agit.

Dessiner un menu principal

Grâce à notre image de titre et à notre police, on peut maintenant réaliser le début de notre menu principal :

#include <gint/display.h>
#include <gint/keyboard.h>

int main(void)
{
    extern bopti_image_t img_title;
    extern bopti_image_t img_levels;
    extern font_t font_mystere;

    dfont(&font_mystere);

    dclear(C_WHITE);
    dimage(0, 2, &img_title);

    for(int i = 1; i <= 8; i++)
    {
        int x = 20 + 11*(i-1);
        int y = 36;

        if(i != 8)
        {
            dsubimage(x, y, &img_levels, 0,0,10,10, DIMAGE_NONE);
            dprint(x+3, y+2, C_BLACK, "%d", i);
        }
        else
        {
            dsubimage(x, y, &img_levels, 11,0,10,10, DIMAGE_NONE);
        }
    }

    dupdate();

    getkey();
    return 1;
}

Il y a pas mal de choses à dire sur cette nouvelle fonction. D'abord, les trois premières lignes :

extern bopti_image_t img_title;
extern bopti_image_t img_levels;
extern font_t font_mystere;

Ce sont des déclarations de variables. Les deux premières variables sont du type bopti_image_t, ce sont les images du titre et des niveaux. (bopti c'est le nom d'un composant de gint chargé de l'affichage des images, c'est lui qui est derrière dimage() et dsubimage() qu'on va voir très vite. Le "_t" à la fin est une convention qui signifie "type".) La dernière est du type font_t, c'est notre police (pas fou je sais ).

Les deux sont marquées extern, ce qui est très important : ça signifie qu'on ne crée pas de variable (on les déclare mais on ne les définit pas, dans le jargon) ; on indique seulement au compilateur que ces variables existent ailleurs et on lui promet de les lui fournir quand il en aura besoin. Ces variables sont en fait créées par fxconv lorsqu'il convertit les images et polices.

Désormais, plus besoin de tableaux longs et moches du genre const unsigned char title[256] = { } à copier-coller partout dans votre code, il vous suffit d'enregistrer votre image ou police dans assets-fx, de la déclarer dans fxconv-metadata.txt et CMakeLists.txt, et vous pouvez tout de suite l'utiliser dans le code avec extern. Le fxSDK se charge de la convertir et de l'ajouter à votre add-in durant la compilation. On verra plus tard comment nommer automatiquement les images pour éviter d'avoir à modifier fxconv-metadata.txt.

dfont(&font_mystere);

Ensuite, on change de police avec un appel à la fonction dfont(), qui prend en paramètre la nouvelle police à utiliser. Vous noterez qu'il faut donner « l'adresse » de la variable (c'est ce petit "&" devant le nom). Si vous ne savez pas ce que c'est, pas grave. Retenez juste que dès que vous utiliserez une image ou une police de gint provenant de l'extérieur du programme il faudra systématiquement mettre ce "&" (libimg est la seule exception jusqu'ici).

Et c'est tout en fait, après cet appel à dfont(), dtext() affiche du texte avec notre police personnalisée. On peut alors effacer l'écran et dessiner l'image du titre :

dclear(C_WHITE);
dimage(0, 2, &img_title);

dimage() est une fonction qui dessine une image complète à la position indiquée. Comme pour dtext(), les coordonnées sont dans l'ordre (x,y), en pixels en partant d'en haut à gauche, et ce sont les deux premiers paramètres. Comme avec la police à l'instant, il faut donner l'adresse de l'image, donc il y a encore un "&" devant "img_title". Contrairement à MonochromeLib vous n'avez plus besoin d'indiquer la dimension de l'image parce qu'elle est stockée avec les pixels !

Ensuite on a la partie dans laquelle on dessine les rectangles des niveaux. Pour les niveaux débloqués, on met le rectangle vide avec le numéro du niveau, et pour les autres on met le cadenas. Bon ici on n'a pas de sauvegarde donc on va prétendre que tous les niveaux sont débloqués sauf le dernier niveau, le 8. La boucle complète ressemble à ça.

for(int i = 1; i <= 8; i++)
{
    int x = 20 + 11*(i-1);
    int y = 36;

    if(i != 8)
    {
        dsubimage(x, y, &img_levels, 0,0,10,10, DIMAGE_NONE);
        dprint(x+3, y+2, C_BLACK, "%d", i);
    }
    else
    {
        dsubimage(x, y, &img_levels, 11,0,10,10, DIMAGE_NONE);
    }
}

Rien de très inattendu ici, on a 8 niveaux donc notre variable i varie de 1 à 8. Les variables x et y sont les coordonnées du coin haut gauche du carré pour chaque niveau ; la hauteur y=36 est constante et la position horizontale varie de 11 pixels à chaque fois, c'est calculé pour que le résultat soit centré.

Dans la boucle, on continue de dessiner. On utilise une nouvelle fonction dsubimage() permettant de dessiner une partie seulement d'une image, c'est comme ça qu'on se débrouille pour avoir uniquement le rectangle ou uniquement le rectangle avec cadenas.

dsubimage() est une version plus générale de dimage() qui permet de dessiner n'importe quelle partie d'une image et possède quelques options. Comme dimage(), on commence par spécifier la position où le résultat doit apparaître à l'écran et l'image source. Ensuite on précise quelle partie de l'image (img_levels) on veut dessiner sous la forme d'un quadruplet (x, y, largeur, hauteur). Par exemple le cadenas est à la position (11,0) dans img_levels et sa taille est 10x10 pixels. Enfin il y a les options, mais pour l'instant on ne va pas s'y intéresser donc on écrit DIMAGE_NONE.

Lorsque le niveau est débloqué, on veut afficher le numéro avec dtext(), sauf qu'on n'a le numéro que sous forme d'entier (notre int i) et qu'on n'a pas sa représentation textuelle. Si la différence vous paraît douteuse, sachez que les bits de l'entier 4 ne sont pas du tout les mêmes que ceux du texte "4", donc il y a un calcul à faire pour passer de l'un à l'autre. C'est vrai dans tous les langages, même si beaucoup vous le cachent (Python par exemple fait automatiquement le calcul dans print() sans vous le dire). Si vous avez déjà fait du C, vous savez que les fonctions de la famille de printf() sont chargées de calculer le texte pour plein de types de variables.

Sans rentrer dans les détails, les fonctions de la famille de printf() utilisent un format qui décrit le texte qu'on veut générer et à quels endroits il faut calculer la représentation textuelle de variables. Ces substitutions s'écrivent %<lettre> (dans leur forme simple) avec une lettre différente pour chaque type de données. Par exemple le format "x=%d" représente un texte contenant "x=" suivi de la valeur d'un entier (d est la lettre qui représente les entiers dans une substitution). Sur un ordinateur, si vous appelez printf("x=%d", 42), le %d est remplacé par la représentation textuelle de 42 et vous obtenez "x=42" dans votre terminal.

Sur la calculatrice on n'a pas de terminal donc on n'a pas printf(), mais gint fournit une fonction dprint() qui fait quasiment pareil. dprint(x,y,fg,format,...) affiche à la position (x,y) et avec la couleur fg le résultat du calcul du format selon les règles de substitution de la famille de printf().

L'appel à dprint() dans la boucle sert donc à écrire la valeur de i dans la case de chaque niveau. Il y a pas mal de paramètres, mais vous pouvez vous souvenir que c'est comme dtext() à part que les substitution sont autorisées (et du coup pour chaque %<lettre> dans le format vous devez ajouter un argument qui indique la valeur à représenter).

C'était un peu gros mais on en reverra. Une fois tout dessiné, on n'oublie pas d'actualiser l'écran avec dupdate() (ce que getkey() ne fait pas pour nous). Et c'est gagné !


Image 7 : Le menu principal prend forme !

Cet état d'avancement correspond au commit f8a880bc8 sur le dépôt du projet.

Le curseur interactif

Il nous reste encore à rendre ce menu interactif. On va créer un curseur simple pour sélectionner les niveaux en inversant les contenus des rectangles, et ensuite on va la faire bouger avec les flèches gauche et droite. Appuyer sur EXE validera la sélection et on quittera l'add-in. Lorsqu'il y aura des sauvegardes, on pourra afficher correctement les numéros des niveaux débloqués mais pour l'instant on va se contenter de tous les débloquer sauf le dernier.

La première chose qu'on va faire, est qui devrait devenir un réflexe pour vous, c'est séparer le code de dessin de la logique du menu. Pour ça, on va se créer un petite fonction draw_menu(), et notre boucle principale pourra se concentrer sur la gestion du clavier.

static void draw_menu(int selected)
{
    extern bopti_image_t img_title;
    extern bopti_image_t img_levels;

    dclear(C_WHITE);
    dimage(0, 2, &img_title);

    for(int i = 1; i <= 8; i++)
    {
        int x = 20 + 11*(i-1);
        int y = 36;

        if(i != 8)
        {
            dsubimage(x, y, &img_levels, 0,0,10,10, DIMAGE_NONE);
            dprint(x+3, y+2, C_BLACK, "%d", i);
        }
        else
        {
            dsubimage(x, y, &img_levels, 11,0,10,10, DIMAGE_NONE);
        }

        if(i == selected)
        {
            drect(x+1, y+1, x+8, y+8, C_INVERT);
        }
    }
}

Si vous ne maîtrisez pas encore les prototypes de fonctions ou les fichiers d'en-tête, prenez soin de mettre cette nouvelle fonction avant main(), parce que le compilateur lit les fichiers de haut en bas et ne serait pas très content de voir main() appeler une hypothétique fonction draw_menu() qu'il n'a encore jamais rencontrée.

Il n'y a pas grand-chose de nouveau à voir dans cette fonction, c'est quasiment la même qu'avant. Je l'ai qualifiée de static, cela veut dire qu'elle ne sera visible que dans main.c quand on ajoutera d'autres fichiers. Après tout, aucune autre partie de l'add-in n'en aura besoin, donc ça ne sert à rien de leur montrer qu'elle existe. Je n'ai pas mis le dfont() ici car il suffit de l'appeler une seule fois au début de l'add-in pour changer la police de façon permanente.

On rencontre une nouvelle fonction, qu'on utilise ici pour inverser la couleur des rectangles. drect(x1,y1,x2,y2,color) remplit le rectangle allant de (x1,y1) jusqu'à (x2,y2) (tous les deux inclus) avec la couleur spécifiée. Ici on utilise la couleur spéciale C_INVERT qui inverse le noir et le blanc.

Regardons donc plutôt les choses intéressantes qui se passent dans main().

int main(void)
{
    extern font_t font_mystere;
    dfont(&font_mystere);

    int selected = 1;
    int key = 0;

    while(key != KEY_EXE)
    {
        draw_menu(selected);
        dupdate();

        key = getkey().key;

        if(key == KEY_LEFT && selected > 1)
            selected--;
        if(key == KEY_RIGHT && selected < 8)
            selected++;
    }

    return 1;
}

Il y a pas mal de choses nouvelles à regarder ici.

• D'abord on a commencé à s'intéresser aux touches ! Pour ça, j'ai stocké la valeur de getkey().key dans une variable. Comme je l'ai mentionné tout à l'heure, getkey() renvoie un événement qui contient plusieurs informations. Il vous dit notamment quelle touche est concernée, si c'est un nouvel appui ou une répétition (les touchées fléchées sont répétées si vous appuyez longtemps dessus), si SHIFT ou ALPHA ont été activés avant l'événement, le moment où l'événement s'est produit... ici j'ignore tout sauf la touche exacte. On reviendra sur les informations utiles renvoyées par getkey() et les options disponibles.

• Ensuite on voit apparaître la logique du menu. La boucle while continue jusqu'à ce qu'on essaie d'entrer dans un niveau avec EXE. On peut se déplacer horizontalement mais bien sûr on vérifie qu'on ne se déplace pas avant le niveau 1 ou après le niveau 8.

• L'écran est redessiné même si on presse une touche inutile comme DEL. C'est pas top, mais c'est loin d'être dramatique comme en Basic Casio.

• Même si la boucle est infinie, on peut toujours quitter l'add-in en appuyant sur MENU puis revenir ensuite, comme avec le GetKey() de fxlib. Par contre si on quitte en sortant de main(), c'est définitif !

Et voilà !


Image 8 : C'était pas si difficile !

Le projet à ce stade peut être consulté au commit 87c9bc3df sur le dépôt.

Conclusion

C'est un menu très schématique et qu'on pourrait facilement améliorer... quelques animations, un peu de décoration, utiliser la touche SHIFT pour valider, et plus encore. Ce tutoriel n'est qu'une brève introduction sur les fonctions fournies par gint mais décrit la majorité de la gestion routinière d'un projet. J'en parlerai moins dans la suite, et ça devrait être plus fun.

Voilà un petit récapitulatif de ce qu'on a vu.

Créer et compiler un projet avec le fxSDK
Ajouter des assets et spécifier leurs paramètres pour fxconv dans fxconv-metadata.txt
Dessin général (<gint/display.h>) : dclear(), drect(), dupdate()
Dessin d'images (<gint/display.h>) : dimage(), dsubimage()
Dessin de texte (<gint/display.h>) : dtext(), dprint(), dfont()
Gestion du clavier (<gint/keyboard.h>) : getkey()
Mon graphe (11 Avril): ((Rogue Life || HH2) ; PythonExtra ; serial gint ; Boson X ; passe gint 3 ; ...) || (shoutbox v5 ; v5)
Lephenixnoir En ligne Administrateur Points: 24706 Défis: 170 Message

Citer : Posté le 21/08/2020 17:03 | #


02 : Un peu de logique temporelle !

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 !


Image 1 : Full graphismes et zéro gameplay. Que demande le peuple ?

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 résistera 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 celui de Zestes de Savoir) ou à poser des questions sur le topic des commentaires si quelque chose vous échappe.

Les bases du moteur : joueurs, niveaux et parties

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 TDM n°7 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.

set(SOURCES
  src/main.c
  src/engine.c
  # ...
)

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.

#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 */

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 ».

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 la version originale sur le dépôt. Enregistrez-la sous le nom assets-fx/spritesheet.png.


Image 2 : Pour les diagonales, on repassera.

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 :

levels.png:
  type: bopti-image
  name: img_levels

title.png:
  type: bopti-image
  name: img_title

spritesheet.png:
  type: bopti-image
  name: img_spritesheet

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.

*.png:
  type: bopti-image
  name: ...

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, fxconv 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.

*.png:
  type: bopti-image
  name_regex: (.*)\.png img_\1

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 vont s'appliquer, 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.

set(ASSETS_fx
  assets-fx/levels.png
  assets-fx/title.png
  assets-fx/spritesheet.png
  assets-fx/font_mystere.png
  # ...
)

Revenons à la conception du jeu.

Le joueur se situe bien sûr à l'intérieur d'un niveau que j'appelle map 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 dans engine.h, avec les dimensions de la map histoire de pouvoir déplacer le joueur sans sortir des bords.

/* struct map: A map with moving doors and collectibles */
struct map
{
    /* Width and height */
    int w, h;
    /* Raw data */
    uint8_t *data;
};

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 (toujours dans engine.h). Ce qui m'incite d'ailleurs à faire un tableau de joueurs, parce que qui sait, le jeu pourrait évoluer dans le futur.

#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];
};

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 !

Structure du programme : affichage, logique, et entrées

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.

Affichage de l'état du jeu à l'écran

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. Contrairement aux structures ci-dessus, cette fois c'est du code pur et dur, donc ça va dans engine.c.

#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);
}

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 "idle" 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.


Image 3 : Tout ce travail et j'utilise qu'un seul frame !

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.


Image 4 : C'est asymétrique spécifiquement pour vous embêter.

Et tout cela suffit pour afficher le jeu complet puisqu'on n'a pas de map.

void engine_draw(struct game const *game)
{
    dclear(C_WHITE);

    for(int p = 0; game->players[p]; p++)
    {
        engine_draw_player(game->players[p]);
    }
}

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.

Logique interne et physique

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() dans engine.c, 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.


Image 5 : Contrairement à Portal, il n'y a pas vraiment de sortie.

/* 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);
}

À 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).

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;
}

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.

Acquisition des entrées

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().

/* 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;
    }
}

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 !

Logique du jeu avec les déplacements

Vous allez voir qu'en fait c'est plus court que ça en a l'air. Ici on est de retour dans main.c.

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;
}

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 8dba2daeb dans l'historique du dépôt.


Image 6 : ... ouais en fait non, c'est tout nul.

Vous comprenez pourquoi j'ai pas mis ça tout en haut du tutoriel. Allez, on va rajouter des animations, ça aidera. ^^"

Structure d'une boucle de jeu animée

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.

/* Time per engine tick (ms) */
#define ENGINE_TICK 25

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 timer (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 à dormir 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 !

Configuration et utilisation d'un timer

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.

#include <gint/timer.h>
#include <gint/clock.h>

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 callback, 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.

static int callback_tick(volatile int *tick)
{
    *tick = 1;
    return TIMER_CONTINUE;
}

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.

/* Global tick clock */
static volatile int tick = 1;

int t = timer_configure(TIMER_ANY, ENGINE_TICK*1000, GINT_CALL(callback_tick, &tick));

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 : « en utilisant un timer quelconque (TIMER_ANY), toutes les ENGINE_TICK*1000 microsecondes, gint va appeler callback_tick(&tick) ».

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.

if(t >= 0) timer_start(t);

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().)

if(t >= 0) timer_stop(t);
return 1;

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).

Boucle de jeu principale animée

En mettant bout à bout tout ce qu'on a raconté jusqu'ici, voici la nouvelle forme de la boucle principale du jeu dans main.c, juste après le lancement du timer.

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);
}

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() de game.c pour renvoyer un nombre négatif sans attendre si aucune pression de touche n'attendait d'être traitée.

/* 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;
    }
}

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 d'après les événements que vous avez lus. 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é.

Animations structurées

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.

/* 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;
};

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() dans engine.c 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.

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);
    }
}

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 30ab7bae0 sur le dépôt. Voyez notamment animation.c si le code détaillé des animations vous intéresse.

Il est temps de regarder ce que tout ça nous donne sur la calculatrice... !


Image 7 : Toujours pas de gameplay, mais fichtre c'est beau.

Magnifique !

Conclusion

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()
Mon graphe (11 Avril): ((Rogue Life || HH2) ; PythonExtra ; serial gint ; Boson X ; passe gint 3 ; ...) || (shoutbox v5 ; v5)
Lephenixnoir En ligne Administrateur Points: 24706 Défis: 170 Message

Citer : Posté le 23/12/2020 18:03 | #


03 : Des portes... beaucoup de portes.


Dans ce tutoriel, on va passer le jeu au niveau supérieur. Jusqu'ici, on a codé un menu principal et un petit moteur de déplacement avec des animations. Pour l'instant, on a laissé la map de côté et on a hardcodé les positions des murs pour éviter que le joueur ne sorte de l'écran. Tout cela change aujourd'hui, parce qu'on va ajouter une map animée et les premières mécaniques de jeu ! Plus précisément :

• On va voir comment convertir des objets personnalisés (ici une map) avec fxconv ;
• On va étendre le moteur de jeu pour intégrer des éléments dynamiques sur la map ;
• Et je vais coder suffisamment de mécaniques pour faire marcher le premier niveau.

Le final ressemblera à ça !



Image 1 : Si je vous spoile la solution y'a plus vraiment de mystère !

Il y a un peu moins de gint ici, et un peu plus de code du jeu. Si vous programmez votre propre jeu en lisant ce tutoriel, faites bien attention à la structure du code. Comme j'en ai parlé dans le tutoriel précédent, il est très important de séparer le rendu, la simulation du jeu et la gestion des entrées avant que le programme ne devienne monstrueux et impossible à maintenir (... ce qui arrivera tout seul sauf si vous y portez une attention particulière ).

Format de la map en tant qu'asset

Pour l'instant, c'est parti ! La map du premier niveau ressemblera à ça. Ça n'inclut pas toutes les mécaniques, mais les grandes idées y sont. On a la zone de départ en bas à gauche, la zone d'arrivée en bas à droite, et le but c'est de passer de l'un à l'autre en utilisant l'ouverture et la fermeture automatique des portes, dont il existe deux types : les horizontales et les verticales.



Image 2 : Le titre a pas menti, y'a beaucoup de portes.

Je pourrais tenter de générer la map directement à partir de l'image, mais ça serait assez compliqué. Pour ce projet tout simple qui n'a que 8 maps toutes de la même taille, je vais simplement fournir une image « squelette » du niveau (juste les murs en gros) et le programme affichera les points de départ et d'arrivée, les portes, et autres items par-dessus. Le rôle et la position des objets seront décrits dans un fichier texte qu'on va convertir avec fxconv parce que ce serait quand même dommage d'avoir à décrire une map en C.

Un vrai projet super ambitieux éditerait certainement les maps dans un outil spécialisé comme Tiled, auquel cas le fichier texte serait remplacé par le fichier de sauvegarde de Tiled. Le principe serait le même, je vais simplement me contenter du fichier texte par simplicité pour ce tutoriel. Il ressemblera à ça :

#####   #####
#   #####   #
#    b a    #
##A### ###A##
#    a b    #
# ~ ##### @ #
#####   #####

a: #.
b: .#
A: #.

Les # représentent des murs, les lettres minuscules représentent des portes verticales et les lettres majuscules des portes horizontales. ~ et @ sont le point de départ et le point d'arrivée, respectivement.

À la fin, il y a des informations qui décrivent le cycle d'ouverture/fermeture de chaque porte (certaines portes ont le même cycle et donc utilisent la même lettre). Je ne voudrais pas vous gâcher les mécaniques donc pour l'instant je n'en dis pas plus.

Format de la map en tant qu'objet du programme

Pour obtenir ces informations dans le jeu, on va commencer par ajouter dans le code (plus précisément dans engine.h) la structure qu'on veut obtenir à la fin. La conversion de la map devra prendre en entrée le fichier texte ci-dessus et produire en sortie une structure de map, donc c'est important de bien définir le format tout de suite. J'avais donné un début dans le tutoriel précédent, qu'on peut maintenant compléter avec les détails en plus.

/* struct map: A map with moving doors, collectibles, and fog */
struct map
{
    /* Width and height */
    int w, h;
    /* Whether fog is enabled */
    int fog;
    /* Door cycle string */
    char door_cycle[128];
    /* Mapping of door types to cycle string index */
    uint8_t door_cycle_index[16];
    /* Background image */
    bopti_image_t *img;
    /* Array of tiles in row-major order */
    uint8_t *tiles;
};

Je stocke les cycles à la suite les uns des autres dans une seule chaîne door_cycle, et je référence chaque portion dans le tableau door_cycle_index (il y a un exemple plus bas). Le plus important ici reste la map, et pour la map on a besoin de donner une valeur à chaque case :

/* enum map_tile: Single cell in the map */
enum map_tile
{
    TILE_AIR   = 0,
    TILE_WALL  = 1,
    TILE_START = 2,
    TILE_END   = 3,

    /* 8 keys in interval 16..23 */
    TILE_KEY   = 16,
    /* 8 vertical doors in interval 24..31 */
    TILE_VDOOR = 24,
    /* 8 horizontal doors in interval 32..39 */
    TILE_HDOOR = 32,
};

Je numérote l'air, les murs, les points de départ et d'arrivée de 0 à 3. Les clés et les portes sont pas toutes identiques donc je me donne 8 numéros de clés et 8 numéros de chaque direction de porte pour avoir de la variété.

Moralement tiles dans la struct map c'est un tableau de enum map_tile. Mais comme les énumérations sont des int par défaut ça voudrait dire que chaque case de la map occupe 4 octets en mémoire, et jeux avoir un type qui occupe juste 1 octet. Donc j'ai mis uint8_t à la place ; ça revient au même vu que tout ça sont des entiers.

Donc voilà ce qu'on doit produire : une structure comprenant 156 octets de données plus un pointeur (de 4 octets) pointant vers l'image de fond, et un autre pointeur (de 4 octets) pointant vers w*h octets de données que l'on va générer en même temps que la structure. Voyons voir comment faire ça avec fxconv.

Conversion de la map avec fxconv

On ne dirait pas de loin, mais fxconv n'est pas juste un outil en ligne de commande. En fait, il est programmable. On peut ajouter dans chaque projet des conversions personnalisées programmées en Python.

D'abord, on va enregistrer la map dans un autre sous-dossier de assets-fx pour ne pas trop mélanger nos assets. Prenons par exemple assets-fx/map/lv1.txt. Pour les métadonnées, on va utiliser custom-type au lieu de type pour indiquer à fxconv qu'on utilise une conversion personnalisée. Comme j'ai mis un sous-dossier, je crée bien un nouveau fxconv-metadata.txt dans assets-fx/map.

*.txt:
  custom-type: map
  name_regex: (.*)\.txt map_\1

Le type est donc un type personnalisé "map" ; la regex est la même que dans le tutoriel précédent, mais avec l'extension .txt pour les noms de fichiers et le préfixe map_ pour les noms de variables.

Maintenant, on peut coder le convertisseur. Je vais le mettre dans assets-fx/converters.py ; vous pouvez le mettre où vous voulez. Voilà à quoi doit ressembler le fichier :

import fxconv

def convert(input, output, params, target):
    if params["custom-type"] == "map":
        convert_map(input, output, params, target)
        return 0
    else:
        return 1

def convert_map(input, output, params, target):
    # Generate the data here...
    data = b"<Placeholder>"
    fxconv.elf(data, output, "_" + params["name"], **target)

Il y a plusieurs choses à noter.

• D'abord le fxSDK contient un module Python fxconv qui fournit quelques fonctions utilitaires et l'indispensable fonction elf dont je reparle dans un instant. Votre boulot se réduit (essentiellement) à lire le fichier d'entrée et à produire des objets bytes() avec les octets de la variable finale, ici la struct map.

• La fonction convert est appelée avec 4 paramètres : input et output sont les noms du fichier d'entrée et du fichier de sortie, respectivement. params est un dictionnaire avec les paramètres indiqués dans fxconv-metadata.txt (ainsi que "type" indiquant le type de la conversion, et "name" indiquant le nom de la variable à créer), et target indique la calculatrice cible (Graph mono ou Graph 90+E), le compilateur à utiliser, et d'autres détails. params["name"] est toujours défini même si vous avez mis name_regex (la regex est appliquée plus tôt).

params["custom-type"] est le type personnalisé spécifié dans fxconv-metadata.txt. J'aime bien faire un if/else ici et avoir une sous-fonction par type de données pour ne pas tout mélanger. Si vous reconnaissez le type, vous devez renvoyer 0 ; sinon, vous devez renvoyer 1 auquel cas fxconv tentera le convertisseur personnalisé suivant (et si tous renvoient 1, il indiquera que le type est inconnu).

• À la fin de la génération, la fonction fxconv.elf() est utilisée pour générer le fichier de sortie. Sauf si vous faites des trucs très extravagants, les trois derniers paramètres ne changeront jamais, et vous n'aurez besoin de output et target que là. Avec ces simplifications en tête, finalement le procédé de conversion prend en entrée input (fichier d'entrée) et params (infos de fxconv-metadata.txt) et doit générer data.

Ici vous pouvez voir que les octets que je génère sont juste ceux du texte "<Placeholder>" encodé en ASCII. Ça n'a rien à voir avec une struct map, c'était juste pour garder le code court pendant l'explication. On va voir juste après le code de la conversion à proprement parler !

Pour que fxconv utilise le convertisseur, il faut le déclarer dans CMakeLists.txt (où vous voulez) :

include(Fxconv)
fxconv_declare_converters(assets-fx/converters.py)

Et ensuite il sera automatiquement appelé avec les fichiers à convertir qui ont des types personnalisés. Tous ces fichiers ne sont pas forcément des maps si vous avez plus d'une conversion personnalisée, c'est pour ça qu'il est important de vérifier le type au début de convert().

Avec ça, vous pouvez compiler et voir que le fichier est converti (en une chaîne d'octets fixe). L'infrastructure étant en place, regardons maintenant la conversion de la map.

def convert_map(input, output, params, target):
    TILE_AIR   = 0
    TILE_WALL  = 1
    TILE_START = 2
    TILE_END   = 3
    TILE_KEY   = 16
    TILE_VDOOR = 24
    TILE_HDOOR = 32

    # Read input file

    with open(input, "r") as fp:
        tiles, cycles = fp.read().split("\n\n")
        tiles = tiles.split("\n")
        cycle_texts = [c for c in cycles.split("\n") if c]

    w = max(len(t) for t in tiles)
    h = len(tiles)
    fog = 0

    filename = os.path.splitext(os.path.basename(input))[0]

    # Generate map contents

    encoded_tiles = bytearray(w * h)
    for (y, t) in enumerate(tiles):
        for (x, c) in enumerate(t):
            if c == " ":
                tile = TILE_AIR
            elif c == "#":
                tile = TILE_WALL
            elif c == "~":
                tile = TILE_START
            elif c == "@":
                tile = TILE_END
            elif ord("0") <= ord(c) <= ord("9"):
                tile = TILE_KEY + int(c)
            elif ord("a") <= ord(c) <= ord("z"):
                tile = TILE_VDOOR + (ord(c) - ord("a"))
            elif ord("A") <= ord(c) <= ord("Z"):
                tile = TILE_HDOOR + (ord(c) - ord("A"))
            else:
                raise fxconv.FxconvError(f"unknown tile character {c}")

            encoded_tiles[y*w + x] = tile

    # Parse door cycles

    RE_CYCLE = re.compile(r'^([a-zA-Z]):\s*([#.]+)$')
    cycles = dict()

    for c in cycle_texts:
        m = re.match(RE_CYCLE, c)
        if not m:
            raise fxconv.FxconvError(f"cannot parse door cycle '{c}'")
        cycles[m[1]] = m[2].encode("utf-8")

    # Generate door cycle data

    door_cycle = b""
    door_cycle_index = bytearray(16)

    for index, letter in enumerate("abcdefghABCDEFGH"):
        door_cycle_index[index] = len(door_cycle)
        if letter in cycles:
            door_cycle += cycles[letter] + b" "

    door_cycle = door_cycle + bytes(128 - len(door_cycle))

    # Generate the structure

    o = fxconv.ObjectData()
    o += fxconv.u32(w) + fxconv.u32(h) + fxconv.u32(fog)
    o += door_cycle + door_cycle_index
    o += fxconv.ref(f"img_{filename}")
    o += fxconv.ref(encoded_tiles)

    fxconv.elf(o, output, "_" + params["name"], **target)

C'est un peu long, mais il y a de tout dans cette conversion donc c'est une bonne référence. On va donc prendre le temps de tout bien détailler.

On commence par lire le fichier source input. Ici c'est un fichier texte, je le lis directement avec open(). Quand c'est une image, vous pouvez utiliser PIL pour la charger directement et avoir un joli objet idiomatique Python pour faire toutes les opérations dont vous avez besoin. Si vous voulez des exemples de ça, il en y en a plusieurs dans les sources de fxconv (le plus simple étant convert_libimg_fx()).

La suite est spécifique à mon format de map. Un ligne blanche ("\n\n") sépare les contenus de la map (tiles) des infos sur les cycles de portes (cycle_texts), et je sépare encore les deux en lignes. Je devine la taille de la map à partir du texte saisi, et pour l'instant je fixe fog à 0. Je récupère dans le nom de fichier ("lv1.txt") la partie sans extension ("lv1") comme ça je pourrai automatiquement mettre un pointeur vers l'image du même nom (qui sera "img_lv1").

Enfin arrive la conversion de la map à proprement parler. Vous pouvez voir que je commence avec un tableau de w*h octets, qui sont tous 0 (TILE_AIR) initialement. Ensuite je remplis en lisant les caractères de chaque ligne. Je note les clés 0...9, les portes verticales a...h et les portes horizontales A...H.

Si je tombe sur quelque chose d'imprévu, je lève une exception de type fxconv.FxconvError, que fxconv affiche à l'écran avant de renvoyer une erreur au Makefile. Je vous conseille de vraiment blinder vos conversions contre les imprévus. Si une erreur se glisse mais n'est pas détectée, l'objet converti sera inutilisable mais vous n'en serez pas averti·e. C'est comme si votre programme contient une erreur mais le compilateur le laisse passer sans rien dire : le résultat n'aura pas de sens et il sera très difficile de réaliser que le problème vient de là.

L'étape suivante consiste à analyser le texte des cycles pour obtenir une représentation structurée. Si vous ne connaissez pas les regex ce n'est pas grave, vous pouvez admettre qu'à la fin cycles est un dictionnaire du type {"a": b"#.", "A": b"#."}. Tout ou presque est en bytes au lieu de chaînes de caractères parce que c'est ce dont on aura besoin lors de la génération du fichier de sortie à la fin de la fonction.

La troisième étape consiste à encoder ces informations en une paire de variables door_cycle et door_cycle_index de la structure. En gros door_cycle c'est la concaténation de tous les cycles ensemble avec un espace entre chaque (ici b"#. #. "), et door_cycle_index indique où commence chaque cycle (ici celui de "a" commence à la position 0 et celui de "A" commence à la position 3). Ce format est pratique parce que toutes les informations tiennent dans deux variables de taille fixe.

Remarquez que dans la structure, j'ai annoncé que door_cycle ferait 128 octets et door_cycle_index 16 octets, donc je suis obligé de tenir parole et respecter ces tailles. Dans le premier cas, j'ai une ligne qui rajoute des 0 à door_cycle pour amener la taille à 128. Dans le second cas, je crée le tableau directement avec 16 octets dans le constructeur bytearray(16) et ensuite je ne touche plus à la taille.

Ensuite, on génère la map. Ce code-là est super important donc je vous le remets ici.

o = fxconv.ObjectData()
o += fxconv.u32(w) + fxconv.u32(h) + fxconv.u32(fog)
o += door_cycle + door_cycle_index
o += fxconv.ref(f"img_{filename}")
o += fxconv.ref(encoded_tiles)

fxconv.elf(o, output, "_" + params["name"], **target)

La génération du fichier final c'est la toute dernière ligne qui appelle fxconv.elf(). Les trois derniers paramètres sont toujours les mêmes, le seul qui importe vraiment c'est le premier, qui représente les données à mettre dans le fichier (c'est-à-dire les octets de la struct map qu'on est en train de convertir).

Si tous les octets de la structure sont connus au moment de la conversion, vous pouvez passer un objet bytes à fxconv.elf(), comme ceci :

# N'importe quoi de type bytes
data = b"..." + bytes([0x00, 0x10, 0x42, ...]) + ...
# On génère exactement ces octets-là
fxconv.elf(data, output, "_" + params["name"], **target)

Le problème c'est que notre structure contient des pointeurs :
• Il y a un pointeur img vers une variable externe.
• Il y a un pointeur tiles vers un tableau qu'on veut générer en même temps que la structure.

Et ça on ne peut pas connaître leur valeur durant la conversion (l'explication complète de ce problème est détaillée dans la prochaine section). Il y a un mécanisme pour délayer leur calcul, mais c'est un peu compliqué et donc fxconv fournit une classe fxconv.ObjectData() qui fait tout ça automatiquement. Pour créer une structure qui contient des pointeurs, vous commencez par créer un objet vide type ObjectData(), et ensuite vous pouvez lui ajouter successivement des composants de trois types avec + ou += :

Octets fixes. Si vous ajoutez un objet de type bytes ou bytearray, les octets sont ajoutés directement au résultat de la conversion. Par exemple cette ligne crée les trois premiers int de la struct map.

o += fxconv.u32(w) + fxconv.u32(h) + fxconv.u32(fog)

Pour créer des entiers non-signés, le module fxconv fournit trois fonctions fxconv.u8, fxconv.u16 et fxconv.u32. Elles génèrent respectivement des entiers de 8 bits (1 octet, aka. char ou uint8_t), 16 bits (2 octets, aka. short ou uint16_t) et 32 bits (4 octets, aka. int ou uint32_t). Si vous n'êtes pas familier·ère avec ces histoires de types entiers de taille variable, vous pouvez voir le TDM 18 qui aborde précisément ce sujet.

Pointeurs vers des variables externes. Si vous ajoutez ref("variable"), un pointeur vers la variable est ajouté au résultat de la conversion. Un pointeur fait toujours 4 octets. Par exemple cette ligne ajoute un pointeur vers l'image de fond (ici c'est "img_lv1").

o += fxconv.ref(f"img_{filename}")

La commande exacte est ref(<variable> [,offset=<offset>]), ça ajoute un pointeur vers (void*)&variable + offset. Attention l'offset est en octets et pas en taille d'objets comme en C.

Pointeurs vers des données annexes. Si vous ajoutez ref(octets)octets est soit un bytes soit un bytearray, alors les octets sont ajoutés en annexe du résultat et un pointeur vers le résultat est ajouté à la structure. Par exemple, cette ligne ajoute les octets de encoded_tiles en annexe de la struct map et un pointeur vers cette annexe à la structure.

o += fxconv.ref(encoded_tiles)

La commande complète est ref(<bytes_like> [,padding=<padding>]). Si vous spécifiez padding alors ref() rajoutera quelques octets nuls à la fin pour s'assurer que la taille du résultat est un multiple de padding. C'est utile pour préserver l'alignement !

Et ensuite, on termine avec fxconv.elf() qui déplie tout, génère les octets et les références pour les pointeurs, et produit le fichier de sortie avec la structure nouvellement convertie.

Remarquez que du coup la map référence img_lv1 donc il faut fournir assets-fx/img/lv1.png sinon l'édition des liens échoue puisqu'il lui manque des variables.

Ce système de conversion est largement supérieur à simplement déclarer des variables avec les contenus dans le code, pour plusieurs raisons !

• Si vous éditez à la main, vous avez toute la liberté de choisir un format agréable à saisir sans compromettre l'utilisation d'une format optimisé dans le code. Par exemple mon stockage des cycles de portes serait horrible à faire à la main.
• Si vous utilisez un éditeur de niveaux maison, il est très dur (sinon impossible) de lire un niveau à partir de sa struct map écrite en C, donc en fait il est probable qui vous ayez déjà un format à vous. Dans ce cas fxconv s'intègre plus facilement à la chaîne de compilation qu'un générateur de code C, et le convertisseur Python est généralement plus simple ou plus court.
• Vous pouvez utiliser des éditeurs externes comme Tiled ou Aseprite si vous prenez le temps d'analyser leur format de fichier. (Est-ce que fxconv supportera directement ces formats un jour ? Peut-être. )
• Pour toute modification, il suffit d'enregistrer le fichier texte décrivant la map, recompiler, et tout est automatique. Si vous aviez l'habitude de Sprite Coder ça élimine totalement ce genre d'étape intermédiaire.

Parenthèse technique : pourquoi gérer les pointeurs différemment ?

On vient de voir que les pointeurs doivent être gérés spécialement, et qu'on ne peut pas connaître leur valeur du tout pendant la conversion.

La raison de tout ça c'est que les pointeurs ce sont des adresses dans la mémoire (voir cette section du TDM 19 pour les détails), et donc pour connaître leur valeur il faut savoir où seront chargées les données pointées (l'image de fond et le tableau qui accompagne la struct map) dans la mémoire.

Sauf que cette information n'existe pas du tout à ce stade ! Choisir des adresses de chargement est le rôle de l'éditeur de liens, qui n'intervient que plus tard dans le processus (qui est détaillé dans le TDM 16). Il est impossible de connaître les adresses durant la conversion et c'est parfaitement normal.

En fait, le même problème se pose lorsque vous écrivez du code C avec des variables globales de ce genre :

char x = 2;
char *px = &x;
char *px8 = &x + 8;

Lorsque ce fichier est compilé, le compilateur génère les octets qui serviront à initialiser ces trois variables. Pour x c'est facile, il y a un seul octet qui est 02. Mais pour px et px8 le compilateur ne peut pas déterminer la valeur initiale, puisqu'elle dépend de &x et que c'est l'éditeur de liens qui fixe &x bien plus tard.

Pour résoudre ce problème, le compilateur utilise un mécanisme de références conçu spécifiquement pour ça. Le compilateur génère les octets 00 00 00 00 pour px et 00 00 00 08 pour px8, et note quelque part dans le fichier .o (le résultat de la compilation) que ces deux valeurs sont relatives à &x. L'éditeur de liens, lorsqu'il choisit l'adresse dans la RAM où x sera chargé, ajoute &x aux deux valeurs, qui deviennent alors correctes.

La capacité de l'éditeur de liens à modifier à la volée des valeurs relatives à des adresses de variables est très puissante et permet sur un ordinateur de compiler efficacement des programmes immenses, d'avoir des bibliothèques dynamiques, et même de charger du code à l'exécution (par exemple des mods pour votre jeu préféré).

Je pense que vous voyez où je veux en venir. Durant notre conversion de map, on ne spécifie pas nous-mêmes les valeurs des pointeurs img et tiles, on se contente de dire que c'est des valeurs relatives à des adresses de variables, et l'éditeur de liens se charge de déterminer la valeur pour nous bien plus tard juste avant de produire le fichier g1a/g3a.

J'ai brièvement mentionné que le compilateur ajoute à son fichier de sortie (un fichier objet .o) une sorte d'« annotation » indiquant quelles valeurs sont relatives et relatives à qui. fxconv produit aussi des fichiers .o (durant l'appel à fxconv.elf ; ELF est le nom du format des fichiers .o), et bénéficie donc de ce mécanisme.

Par contre, créer les annotations c'est laborieux surtout à la main. Du coup fxconv produit une description en assembleur de la structure et la fait assembler sur-le-champ, puisque as sait très bien produire toutes les annotations. Donc en fait ce que ObjectData() vous cache c'est qu'il génère du texte assembleur pour référencer les symboles, et ensuite fxconv.elf() invoque l'assembleur pour produire les annotations.

Notez que vous pouvez toujours joindre du code assembleur au résultat de la conversion vous-même (notamment pour générer des symboles annexes ou véritablement du code exécutable) en donnant une valeur au paramètre optionnel assembly de fxconv.elf().

Utilisation de la map dans le programme

Maintenant qu'on a des struct map complètes et un premier niveau disponible dans le programme, on peut modifier le moteur pour exploiter toutes les nouvelles données. Au début de la fonction engine_draw(), je rajoute un dimage() pour afficher l'image de fond du niveau :

void engine_draw(struct game const *game)
{
    dclear(C_WHITE);
    dimage(0, 0, game->map->img);

    /* ... */
}

Dans map_walkable(), on peut retirer le test qui hardcodait les bords de l'écran comme murs et lire les données qu'on vient de générer dans la map.

/* Check whether a cell of the map is walkable */
static int map_walkable(struct map const *map, int x, int y)
{
    int tile = map->tiles[y * map->w + x];
    return (tile != TILE_WALL);
}

Pour l'instant j'ignore les portes, on verra ça après !

Et enfin, on peut arrêter d'utiliser un map factice dans main() et véritablement charger la map du niveau 1.

extern struct map map_lv1;

struct game game = {
    .map = &map_lv1,
    .players = { &singleplayer, NULL },
    .time = 0,
};

Et voilà, on peut maintenant se déplacer sur la map et les collisions sont fonctionnelles !



Image 3 : Toujours pas de gameplay mais pour ma défense on s'en approche.

Vous pouvez consulter le code à ce stade au niveau du commit 80167969f sur le dépôt.

Animation des tiles sur la map

Dans cette partie j'ai surtout codé le jeu sans apporter beaucoup de modifications spécifiques à gint. La principale modification a été d'ajouter dans la struct game décrivant la partie en cours un tableau d'informations dynamiques de tiles. Comme la struct map qui est issue de la conversion est en lecture seule (comme tout objet issu de la conversion, par défaut), il est hors de question de la modifier. Donc toutes les infos dynamiques sont dans la struct game.

Pour implémenter les mécaniques et gérer les animations, j'ai défini un concept de « tile dynamique ». Ce sont des tiles qui peuvent changer d'état pendant le jeu. Les données d'une tile dynamique comprennent leur position sur le map, leur état actuel, et une animation.

/* struct dynamic_tile: Dynamic tile information */
struct dynamic_tile
{
    /* Position */
    int x, y;
    /* Current state */
    int state;
    int state2;
    /* Whether animation is idle */
    int idle;
    /* Current animation */
    struct anim_data anim;
};

Le système d'animations que j'utilise pour le joueur fonctionne tout à fait pour les tiles aussi, en fait il décrit simplement des transitions entre des images de différentes durées. Pour ceux que ça intéresse, voilà comment ça se passe :

/* 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;
};

Une animation contient essentiellement une référence à l'image en cours (img) qui est une sous-image d'une spritesheet ou tilesheet, des paramètres d'affichage et de direction, le numéro du frame actuel de l'animation (frame) et la durée restante avant le prochain frame (duration).

À chaque update le moteur du jeu réduit duration de la durée écoulée depuis l'update précédente (dans mon cas c'est fixé à 25 ms) et quand duration tombe à 0 ou en-dessous de 0, la fonction d'animation function est appelée pour passer au frame suivant.

On peut regarder par exemple la fonction d'animation de la tile de début de niveau.

int anim_tile_start(struct anim_data *data, int init)
{
    data->function = anim_tile_start;
    data->frame = init ? 0 : (data->frame + 1) % 5;
    data->duration = (data->frame == 0 ? 1000 : 150);
    data->img = anim_frame(&anim_tiles, data->frame, 0);
    return 0;
}

Cette fonction commence par indiquer qui devra être appelé pour la prochaine update dans data->function (elle-même) et le prochain frame à afficher (le premier quand on initialise, le suivant modulo 5 sinon). On en déduit la durée (qui ici est 1 seconde pour le frame initial est 150 ms pour les autres) et enfin l'image à afficher (anim_frame() sert juste à extraire une sous-image dans la tilesheet).

Dans le cas d'une animation comme l'ouverture de porte, lorsqu'on atteint le frame final on veut faire une transition vers l'animation pour les portes qui sont ouvertes et à l'arrêt, et dans ce cas il y a une condition qui teste si on a atteint la fin de l'animation d'ouverture et appelle la fonction d'animation des portes ouvertes à l'arrêt quand ça se produit.

Tout ça donc marche aussi bien pour le joueur que pour les tiles. Ce n'est probablement pas la meilleure manière d'implémenter des animations, mais c'est relativement flexible et ça évite au moteur d'animations de s'inscruter dans le moteur de jeu.

Revenons aux tiles dynamiques du coup. Au chargement du niveau, des tiles dynamiques sont crées pour l'entrée, la sortie, les portes et le clés. Les animations pour l'entrée et la sortie tournent en boucle. Les animations des portes sont séparées en deux fixes (porte ouverte et porte fermée) et deux transitoires (porte en cours d'ouverture et portes en court de fermeture).

L'état des tiles dynamiques est utilisé pour stocker la progression de chaque porte dans son cycle d'ouverture/fermeture, et indiquer le cas particulier où une porte est bloquée en position ouverte par un joueur (si le joueur entre juste avant qu'elle n'essaie de se fermer).

Premiers éléments de gameplay

À ce stade je pense que le gameplay est clair pour vous. On veut traverser chaque niveau de son entrée vers sa sortie, mais il y a des portes partout et elles passent leur temps à s'ouvrir et se fermer. Et donc on tourne en rond en essayant de comprendre les cycles.

Pour implémenter réellement le gameplay il suffit de faire avancer chaque porte d'une étape dans son cycle à la fin de chaque tour, et d'empêcher le joueur de se déplacer sur une case où il y a une porte fermée.

Et voici, chers lecteurs, le premier niveau de Mystère noir et blanc !



Image 4 : Gamer focus activated!

Vous pouvez jouer à ce niveau sur toutes les plateformes supportées par gint (pour un add-in de cette taille, normalement toutes les Graph mono) en téléchargeant MystNB.g1a sur le dépôt (lien direct) au niveau du commit c2178fd2b.

Conclusion

Ce tutoriel présente toutes les notions nécessaires à la conversion d'assets spécifiques pour un jeu avec fxconv. En plus de la map qu'on a convertie ici, on peut convertir des descriptions d'objets, des séquences de dialogues, des cinématiques, des listes de quêtes, des traductions de textes, et tous autres assets spécifiques à des applications et qui peuvent être représentés et édités dans des formes plus agréables que du code.

Voilà ce qu'on a abordé dans cet épisode.

• Comment étendre fxconv avec des conversions personnalisées en Python.
• Comment convertir un objet en une structure définie en C.
• L'utilisation de fxconv.ObjectData() pour générer des structures contenant des pointeurs.
• Et une petite discussion sur l'implémentation des éléments dynamiques de Mystère noir et blanc.
Mon graphe (11 Avril): ((Rogue Life || HH2) ; PythonExtra ; serial gint ; Boson X ; passe gint 3 ; ...) || (shoutbox v5 ; v5)

Désolé le sujet a été bloqué, vous ne pouvez pas répondre.

Planète Casio v4.3 © créé par Neuronix et Muelsaco 2004 - 2024 | Il y a 25 connectés | Nous contacter | Qui sommes-nous ? | Licences et remerciements

Planète Casio est un site communautaire non affilié à Casio. Toute reproduction de Planète Casio, même partielle, est interdite.
Les programmes et autres publications présentes sur Planète Casio restent la propriété de leurs auteurs et peuvent être soumis à des licences ou copyrights.
CASIO est une marque déposée par CASIO Computer Co., Ltd