Gestion des assets | Blog | Superflu Riteurnz

Gestion des assets | Blog | Superflu Riteurnz Gestion des assets | Blog | Superflu Riteurnz
  • Steam
  • Nintendo Switch
  • Itch.io
  • Google Play
Superflu et son assistante Sophie

Blog

Gestion des assets

2022-05-04

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 et SDL_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 appelle load: ["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 de data/objects/plante.png ;
  • on va lire et exécuter les instructions la section entree dans bureau.yaml. Ces instructions peuvent être stockées dans un fichier séparé, auquel cas on ira chercher data/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 de data/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 !

i