Blog
Gestion des assets
Une partie d'importance capitale dans le développement de jeu vidéo et dont on ne parle que rarement dans les cours de programmation « classique », c'est la gestion des assets, c'est-à-dire des données du jeu (niveaux, images, sons, etc.).
Bon, lire des fichiers en C++, en général, on sait faire, mais là on parle d'avoir un mécanisme automatisé et multiplateforme : vous ne voulez pas que la personne qui joue ait besoin de spécifier à chaque lancement où se trouvent les assets…
L'API
J'en avais déjà brièvement parlé dans la deuxième partie de mon
article sur le portage Android, mais on n'utilise pas les
fonctions d'IO « classiques » du C++ (via std::ifstream
), ni même
celles du C (via FILE*
) : à la place, on va utiliser des fonctions
dédiées de la SDL.
L'avantage, c'est que ces fonctions vont, de manière transparente, se
« brancher » sur la gestion des assets particulière d'Android (ou
d'autre systèmes) qui n'utilise pas un système de fichiers classique,
tout en rebasculant sur l'API de base C FILE*
quand elle est
utilisable. Mais tout ça, c'est la SDL qui s'en charge, on n'a pas à
s'en préoccuper, et c'est bien cool !
Bien sûr, les fonctions de chargement d'image ou de son
(SDL_LoadImage
, Mix_LoadWAV
, etc.) utilisent ce mécanisme, mais si
on veut également charger d'autres types de fichiers (les fichiers de
description de nos niveaux, par exemple), des fonctions plus bas
niveau sont fournies.
Les fonctions d'IO de la SDL commencent en général par SDL_RW
(pour
read/write) :
SDL_RWFromFile
: permet d'ouvrir un fichier classique ;SDL_RWFromConstMem
: permet d'ouvrir une section de la mémoire vive comme si c'était un fichier. J'en parlerai dans un autre article, mais ça peut permettre de précharger des fichiers en mémoire et de les interpréter à un autre moment ;SDL_RWread
: permet de lire un certain nombre d'octets ;SDL_RWtell
etSDL_RWseek
, respectivement pour savoir à quel endroit du fichier on se trouve et pour rendre à un endroit spécifique du fichier ;SDL_RWclose
pour fermer le fichier.
Il y en a d'autres, je ne détaille pas, vous avez la documentation de l'API à disposition.
Le fichier d'initialisation
Bon, comme je le disais, lire des fichiers en C/C++, on sait faire : ici, l'API est un peu différente des API standards, mais pas tant que ça, et ça s'utilise de manière assez intuitive. Maintenant, comment ça se passe concrètement côté code ?
Comme les données du jeu peuvent varier (rappelons que le moteur de
jeu est « agnostique » par rapport au contenu, et que vous pourriez
théoriquement créer un jeu d'aventure point'n'click complètement
différent de Superflu Riteurnz sans changer une ligne de code), il
nous faut une base fixe : cette base, ça va être un fichier
d'initialisation normalisé qu'on va bêtement nommer data/init.yaml
.
Il est assez long, je vous en propose donc quelques extraits :
version: 1.0.0
name: "Superflu Riteurnz"
icon: "icon"
On commence par donner la version minimum du moteur à utiliser : comme le moteur peut évoluer (surtout en phase de développement), les données peuvent devenir incompatibles et je préfère avoir une vérification au lancement.
Ensuite, on donne bien sûr le nom du jeu (qui apparaîtra comme nom du
programme dans la barre de tâches, entre autres) et le chemin de
l'icône : comme j'utilise une hiérarchie normalisée, je ne donne que
le nom du fichier isolé, mais le moteur déduira qu'il faut aller
chercher images/interface/icon.png
.
debug_font: "DejaVuSansMono"
interface_font: "PTN77F"
interface_light_font: "PTN57F"
dialog_font: "TovariSans"
inventory_arrows: [ "gauche", "droite" ]
click_sound: "clic"
Cette section nous dit quelles polices charger ainsi que certaines
images (les flèches pour naviguer dans l'inventaire) et sons (comme le son « clic » de
l'interface). Pareil, les chemins complets sont déduits
(DejaVuSansMono
devient fonts/DejaVuSansMono.ttf
, clic
devient
sounds/effects/clic.ogg
, etc.).
text:
- { id: "continue", value: "Reprendre" }
- { id: "new_game", value: "Nouvelle partie", icon: "nouvelle_partie" }
- { id: "settings", value: "Préférences", icon: "preferences" }
- { id: "phone", value: "Téléphone", icon: "telephone" }
- { id: "gps", value: "GPS", icon: "gps" }
- { id: "credits", value: "Crédits", icon: "credits" }
- { id: "save_and_quit", value: "Sauver & quitter", icon: "quitter" }
On a les éléments du menu avec, à chaque fois, leur titre en français et leur éventuelle icône. Je parlerai dans un prochain article de comment on gère les traductions (en anglais, notamment).
Enfin, on a cet élément :
load: [ "cinematique_intro", "intro" ]
Qui nous dit quel niveau (ou, dans ce cas, quelle cinématique) charger
au lancement du jeu. Cette ligne peut éventuellement être ignorée si
une sauvegarde existe, mais pareil, j'en parlerai une autre fois. Le
second paramètre ("intro"
) désigne quelle « origine » du niveau
utiliser : dans le cas d'une cinématique, il n'y en a qu'une, mais si
vous imaginez une pièce avec plusieurs entrées/sorties (la porte
principale et la porte du jardin, par exemple), il s'agit de choisir à
quel entrée/sortie vous voulez voir votre personnage apparaître.
Je ne détaille pas, j'ai déjà évoqué les descriptions des niveaux dans un article dédié.
L'arborescence
Le fichier data/init.yaml
est donné relativement à la racine de
notre arborescence de données : tous les autres chemins de fichier
sont donnés relativement à cette racine.
Afin de m'y retrouver facilement dans mes assets et de pouvoir
utiliser des références courtes comme expliqué à la section précédente
(icon
au lieu de images/interface/icon.png
), la hiérarchie des
dossiers et les formats de fichiers sont fixés et normalisés :
[racine]
└── data/ # DONNÉES AU FORMAT YAML
│ ├── actions/ # Les séquences d'action décrites
│ ├── characters/ # Comment on « construit » un personnage (quelles images, couleurs, coordonnées, etc.)
│ ├── codes/ # Les codes secrets (digicodes, serrures) à résoudre
│ ├── dialogs/ # Les arbres de dialogue
│ ├── objects/ # Les objets et les interactions possibles avec
│ ├── rooms/ # Les niveaux du jeu
│ ├── hints.yaml # Un fichier contenant les indices accessible via l'aide
│ ├── init.yaml # Le fameux fichier d'initialisation
│ └── locale.yaml # Un fichier de traduction
├── fonts/ # POLICES AU FORMAT TTF
├── images/ # IMAGES AU FORMAT PNG
│ ├── animations/ # Les images animées
│ ├── backgrounds/ # Les fonds des niveaux
│ ├── characters/ # Les images des personnages (têtes, yeux, corps, animations de marche, etc.)
│ ├── interface/ # Icônes, boutons, etc.
│ ├── inventory/ # Les objets affichés dans l'inventaire
│ ├── objets/ # Les objets avec lesquels on peut interagir
│ ├── scenery/ # La décoration, les objets sans interaction possible
│ └── windows/ # Les codes et fenêtres qui peuvent s'afficher au premier plan
└── sounds/ # SONS AU FORMAT OGG
├── effects/ # Les effets sonores joués ponctuellement
└── musics/ # Les musiques des niveaux jouées en fond
En utilisant cette arborescence, on peut facilement créer des niveaux
en YAML
qui vont aller charger tout ce dont ils ont besoin. Par
exemple :
- le fichier
init
appelleload: ["bureau", "entree"]
; - le moteur va donc aller chercher
data/rooms/bureau.yaml
et le charger ; - chaque objet de la pièce va avoir un « skin », par exemple
skin: "plante.png"
qui va donc lancement le chargement dedata/objects/plante.png
; - on va lire et exécuter les instructions la section
entree
dansbureau.yaml
. Ces instructions peuvent être stockées dans un fichier séparé, auquel cas on ira chercherdata/actions/entree.yaml
; - imaginons qu'à l'entrée dans une pièce, un dialogue doive se lancer,
on va avoir
trigger: ["dialogue_debut"]
qui va provoquer le chargement dedata/dialogs/dialogue_debut.yaml
; - etc.
Bien sûr, il faut faire attention à ce que chaque asset référencé
existe bien, sinon le moteur va lever une erreur : histoire de ne pas
devoir systématiquement lancer le jeu pour débusquer les erreurs
cachées dans mes fichiers YAML
, j'ai mis en place un script Python
qui parcourt tous ces fichiers et vérifie, outre la validité de la
syntaxe et des opérations réalisées, que chaque fichier référencé
existe bien.
Pour l'instant, toutes les images sont stockées dans leur dossier (par
exemple images/animations/nuages.png
) mais je ne m'interdis par
d'introduire de nouveaux sous-dossiers si ça devient trop fouillis à la
longue (par exemple images/animations/partie1/nuages.png
). Ce qui se
traduira dans les fichiers YAML
par un remplacement de nuages
par
partie1/nuages
.
Installation
La grande question reste : comment le programme va-t-il savoir où trouver ces données ? On a besoin d'avoir un chemin fixe puisque, encore une fois, la personne qui joue ne doit pas avoir besoin d'indiquer ce chemin au programme.
Le problème, c'est que selon l'endroit où va être installé le programme (et le système d'exploitation), le chemin va quand même varier !
Bonne nouvelle, la SDL nous aide encore beaucoup en nous fournissant
SDL_GetBasePath()
qui va nous renvoyer le chemin où se trouve
l'exécutable (à part si c'est un bundle MacOS, mais je vous renvoie
à la doc pour plus
d'infos). Ce qui est bien
pratique, car on peut donc normaliser le chemin relatif à
l'exécutable et se référer à cette fonction dans le code.
Comme cette fonction alloue la mémoire de la chaîne de caractères, on encapsule ça dans une fonction C++ plus haut niveau :
namespace SDL_file
{
std::string base_path()
{
char* bp = SDL_GetBasePath();
std::string out = bp;
free(bp);
return out;
}
}
Ainsi, sur Gnunux, on va typiquement utiliser l'arborescence standard et donc placer :
- l'exécutable dans
/usr/bin/
(ou/usr/local/bin
pour une installation locale uniquement) - les données dans
/usr/share/superfluous-returnz/
(ou/usr/local/share/superfluous-returnz/
)
(Certains Gnunux préfèrent /usr/game/
au lieu de /usr/bin/
pour
les jeux, mais je simplifie.)
Eh bien du coup, on sait que pour accéder à nos données, on peut
utiliser SDL_file::base_path() + "../share/" + game_id + "/"
(game_id
étant une variable contenant l'identifiant du jeu, ici
superfluous-returnz
, mais rappelez-vous que ça peut changer !).
Sur Windows, classiquement, les données sont dans un sous-dossier du
dossier de l'exécutable, on peut donc simplement utiliser
SDL_file::base_path() + "data"
par exemple.
Il suffit ensuite de copier les données au bon endroit via la procédure d'installation de CMake, et tout roule :
if (CMAKE_SYSTEM_NAME STREQUAL Linux)
target_compile_options(${SOSAGE_EXE_NAME} PUBLIC -DSOSAGE_INSTALL_DATA_FOLDER="../share/${SOSAGE_EXE_NAME}/")
install(TARGETS ${SOSAGE_EXE_NAME} DESTINATION bin)
install(DIRECTORY ${SOSAGE_DATA_FOLDER}/data/ DESTINATION share/${SOSAGE_EXE_NAME})
elseif (CMAKE_SYSTEM_NAME STREQUAL Windows)
target_compile_options(${SOSAGE_EXE_NAME} PUBLIC -DSOSAGE_INSTALL_DATA_FOLDER="data/")
install(TARGETS ${SOSAGE_EXE_NAME} DESTINATION ".")
install(DIRECTORY ${SOSAGE_DATA_FOLDER}/data/ DESTINATION "./data")
endif ()
Dans le code, on n'a plus qu'à récupérer la constante
SOSAGE_INSTALL_DATA_FOLDER
qui variera selon l'OS :
sosage.run(SDL_file::base_path() + SOSAGE_INSTALL_DATA_FOLDER);
Notez qu'une fonction similaire à SDL_GetBasePath()
nommée
SDL_GetPrefPath()
permet quant à elle d'accéder à un répertoire où
vous pouvez stocker des fichiers de jeu (sauvegardes, préférences,
etc.). J'en parlerai une prochaine fois également.
Conclusion
Voilà comment je gère les assets dans Superflu Riteurnz. Il y a sans doute d'autres façons de faire, mais c'est ce que j'ai trouvé de plus flexible sans être trop prise de tête.
Un détail : dans la gestion de la mémoire, les accès disque sont connus pour être extrêmement lents comparés à la mémoire vive (d'autant plus si vous avez un disque dur à l'ancienne et non un SSD). Pour cette raison, on évite en général de multiplier les accès disque : ainsi, au lieu de charger les images une par une, on préfère plutôt charger un gros bloc d'images et les interpréter ensuite une par une en mémoire vive.
Dans un prochain article, je vous parlerai donc de comment on peut utiliser des archives et aussi tirer parti d'algorithmes de compression pour accélérer ce processus. À bientôt !