Comment créer des Assets de données et en finir avec la galère du stockage dans Unity3D

Créer des Assets de données

Vous avez beau chercher vous ne trouvez pas de méthode ou de tutoriel pour stocker correctement les données de votre jeu Unity3D.

C’est plutôt problématique de ne pas avoir de solution de stockage facile d’utilisation. La donnée, c’est un peu le carburant de votre jeu. Mais le besoin restant entier, vous devez quand même trouver une solution.

Alors vous essayez des choses. Vous prenez ce que vous avez sous la main, et avec 3 bouts de ficelle vous bricolez une solution plus ou moins efficace :

  • Du XML
  • Du JSON
  • Des fichiers au format maison
  • Une base SQL
  • De la sérialisation binaire
  • Des Prefabs

Si vous êtes bricoleur/euse ou bon développeur/euse, vous arriverez sans doute à un système de stockage maison qui marche pas trop mal. Mais est-ce qu’il est cross-platform ? Est-ce qu’il est optimisé ? Est-ce que vous pouvez stocker tous les types de données, y compris ceux auquel vous n’avez pas encore pensé ?

Probablement pas. Si c’est le cas, félicitations ! Vous n’avez pas besoin de lire cet article plus longtemps ! 🙂

Il y a deux points distincts à voir ici :

  1. Comment structurer ses données pour les stocker dans son projet
  2. Comment utiliser les outils d’Unity pour rendre ces données utilisables

Structurer ses données

Avant même de parler de technique, il faut vous pencher sur l’aspect structurel de vos données. Vous aurez deux grandes familles de structures à stocker :

  • Des données uniques (boss, piège spécial, …)
  • Des collections (monstres, objets, quêtes, …)

D’un côté vous avez les structures de type “unique”. Comme leur nom l’indique elles sont… uniques. Cette structure pourra être utilisée pour représenter une seule chose. Vous pourrez en revanche avoir plusieurs représentation de cette chose : les stats du boss en facile, les stats du boss en moyen, etc.

D’un autre côté vous avez les structures de type “collection”. Cette structure pourra être utilisée pour représenter des éléments différents : par exemples des armes, des monstres, etc.

Créer sa hiérarchie de données

Pour les données de type “collection”, il faudra vous demander quels sont les points communs de vos données. Vous allez alors les regrouper en familles selon leurs traits communs.

Par exemple, une arme et une potion sont deux objets. Mais ils ont des attributs trop différents pour être mis dans le même panier : une arme inflige des dégâts alors qu’une potion a un effet.

Vous allez donc avoir une structure “arme” et une structure “consommable”, qui sont toutes les deux des structures de type “objet” :

L’intérêt principal de cette hiérarchisation est de mutualiser les données communes pour profiter du principe d’abstraction.

Le concept d’abstraction en résumé :

“Lance” a un nom, “Potion” a un nom, “Caillou inutile” a un nom. Tous ces éléments peuvent être rassemblés dans une famille “Objet”. Donc la propriété “nom” peut être portée par le niveau “Objet” de la structuration. Par contre, une arme va définir « Dommage » et une potion un « Effet ».

Ces données représentent quelque chose de différent, il faut donc les mettre dans des familles différentes. Nous obtenons donc la famille « Arme » et la famille « Consommable » qui sont toutes les deux de la famille « Objet ».

Un « Arme » est un « Objet » avec des éléments en plus. Un « Consommable » est un « Objet » avec des éléments en plus.

Cette hiérarchisation prend toute son importance lors de l’utilisation de ces données dans un script. En effet, chaque description de donnée va être une représentée par une classe. Vous manipulerez des instances de ces classes qui contiendront vos données.

Via le principe d’abstraction vous pourrez déclarer une variable de type « Objet » qui pourra contenir en réalité n’importe quel type enfant de « Objet » : par exemple « Arme » ou « Consommable ».

Pour donner une exemple concret : votre inventaire sera une liste de “Objet” qui contiendra tout un tas d’autres types (potentiellement des armes, des potions, ou des objets inutiles).

ScriptableObject à la rescousse

Bon… assez de théorie. Vous avez structuré vos donnés en amont et vous êtres prêts à créer les conteneurs pour les stocker. Alors comment ça se passe ? En utilisant le ScriptableObject.

Voyons ce que nous dit la documentation officielle :

This is most useful for assets which are only meant to store data.

(C’est très utile pour créer des Assets dont le seul but est de stocker des données.)

Documentation officielle : https://docs.unity3d.com/ScriptReference/ScriptableObject.html

Tout est dit. Le ScriptableObject va devenir votre meilleur ami pour stocker de la donnée dans votre projet.

Hello, my name is ScriptableObject

La mise en place du ScriptableObject est très simple et se résume à 3 étapes :

  1. Créer une classe qui étend ScriptableObject. Cette classe va définir les types de données que l’on veut stocker dans une unité de ce type de structure.
  2. Via le menu contextuel du projet, créer une unité de cette structure qui sera enregistrée dans votre projet comme un Asset.
  3. Ajouter cet Asset en paramètre de vos scripts comme n’importe quel autre type d’Assets. Vous pourrez accéder aux valeurs de cette structure comme bon vous semble et baser vos calculs sur ces valeurs.

Ci-dessous, un exemple de ScriptableObject pour notre type « Objet » :

public class ObjectData : ScriptableObject
{
    public string Name;
    public int Price;
}

Ici nous avons notre structure de base qui contient un nom et un prix. Pour suivre la hiérarchie présentée plus haut, il nous faudrait 2 autres classes pour définir les armes et les consommables :

public class WeaponData : ObjectData
{
    public float Damage;
}
public class ConsumableData : ObjectData
{
    public int Life;
}

Dans les deux exemples plus hauts, vous pouvez voir que la structure étend ObjectData. Comme ObjectData étend ScriptableObject, WeaponData et ConsumableData sont eux aussi des ScriptableObject.

Histoire de ne pas risquer de vous emmêler les pinceaux, je vous conseille vivement de suffixer le nom de ces classes par “Data”. C’est un moyen très simple d’identifier quelles sont les classes de définition d’une structure de données.

Accessoirement, vous éviterez les conflits avec des noms de classe existants dans Unity (GameObject) ou même des noms réservés (Object).

Créer des Assets de données et les utiliser

Nous avons la structure de notre Asset, on voudrait maintenant créer une unité de stockage de cette structure ! Pour cela, il vous faut rajouter un Attribut spécial sur vos définitions de classes :

[CreateAssetMenu(fileName = "NewObject", menuName = "Mes Objets/Objet")]
public class ObjectData : ScriptableObject
{
    public string Name;
    public int Price;
}

Cet attribut prend 2 (ou 3) paramètres :

  • fileName : le nom par défaut de l’Asset créé
  • menuName : le nom du menu.
  • order : l’ordre de l’item de menu dans le menu : cette propriété peut vous servir pour ordonner d’une manière précise vos éléments

Note : vous pouvez créer des sous-menus en mettant des slashs dans le nom. Faites-le. Vraiment. Sinon votre menu de création d’Assets va se transformer en un arbre de noel…

L’utilisation de cet attribut va automatiquement créer une entrée de menu dans le menu de création d’Assets :

Cliquer sur cet item de menu va créer un Asset dans votre projet basé sur la structure associée. Vous pourrez le renommer et le déplacer comme n’importe quel autre Asset classique.

Une fois créé, lorsque vous le sélectionnez dans la fenêtre projet, l’inspecteur va automatiquement afficher des champs permettant de modifier les valeurs de cette structure. Les infos sont enregistrées en temps réel, pas de bouton “sauvegarder”.

Le plus dur est fait. Comme expliqué plus haut, les Assets de type ScriptableObject peuvent être ajoutés en paramètre d’un MonoBehaviour comme tous les autres types de données. Par exemple, un coffre qui donne un objet au joueur :

public class Chest : MonoBehaviour
{
    [SerializeField]
    private ObjectData _object;

    public ObjectData OpenChest()
    {
        Debug.Log("Le joueur a trouvé " + _object.Name);
        return _object;
    }
}

Vous pouvez ajouter l’attribut de création à chacun de vos niveaux de la hiérarchie : donc sur WeaponData et ConsumableData. L’éditeur va alors créer une Asset à partir du script donné :

Notez les propriété communes partagées via la hiérarchie que nous avons construite plus haut !

Mise en garde

Il y a un point important à soulever. Les ScriptableObjects ont un comportement particulier concernant l’enregistrement de leurs valeurs. Si dans un de vos scripts, vous modifiez  une valeur d’un ScriptableObject (y compris en mode Play), la valeurs seront enregistrées dans l’Asset !

Une astuce simple pour éviter ça : empêcher l’écriture des valeurs via les scripts. C’est un peu plus de code à écrire, mais c’est une sécurité qui vous évitera quelques désagréments :

[CreateAssetMenu(fileName = "NewObject", menuName = "Mes Objets/Objet")]
public class ObjectData : ScriptableObject
{
    [SerializeField]
    private string _name;
    public string Name { get { return _name; } }

    [SerializeField]
    private int _price;
    public int Price { get { return _price; } }

}

La solution est simple : cacher les variables derrières des propriétés accessibles uniquement en lecture. L’utilisation de l’attribut SerializeField permet de les laisser accessibles dans l’inspecteur. Le tour est joué, vous pouvez modifier vos valeurs depuis l’inspecteur, mais pas depuis vos scripts.

Gérer de grandes quantités d’Assets de donnée

La structure de stockage des données est là. Mais dans le cadre d’un projet réel, ça ne sera probablement pas suffisant. Si vous avez plusieurs dizaines, voir centaines de fichiers de données, pour un RPG par exemple, il va vous falloir construire un niveau supplémentaire pour gérer toutes ces données. Créer une sorte de base de données :

Cette base de données va lister tous vos Assets de Données et les rendre accessibles simplement, depuis un seul endroit. Pour mettre en place ce système, voyons déjà ce que cela implique

Tout d’abord, il faut pouvoir identifier les Assets de Données avec un identifiant unique. Cet identifiant va nous permettre de retrouver quel Asset de Données on veut récupérer dans le lot. L’idée est de simplifier au maximum l’utilisation pour arriver à quelque chose comme cela :

var swordDataAsset = MyDatabase.GetObject('sword-level-one');

Très simplement, on demande à la base de données de nous fournir l’Asset de Données « sword-level-one ». Pas besoin de définir un lien en dur vers cet asset, c’est la base de données qui s’en charge. L’avantage de cette technique est que vous découplez complètement la gestion des données de votre mécanique de jeu.

Vous changez d’avis quand au contenu d’un coffre ? Vous avez simplement besoin de changer l’identifiant de l’Asset de Données. Tout le reste de votre code restera inchangé. En pratique, c’est un gain de temps assez conséquent qui réduit au passage les risques de bugs (références mal mises à jour).

Nous devons donc identifier nos Assets de Données. Le plus pratique est d’utiliser une chaîne de caractères. C’est plus simple à utiliser. Modifiez donc le ScriptableObject ObjectData pour lui ajouter un identifiant :

[CreateAssetMenu(fileName = "NewObject", menuName = "Mes Objets/Objet")]
public class ObjectData : ScriptableObject
{
    [SerializeField]
    private string _id;
    public string Id { get { return _id; } }

    [SerializeField]
    private string _name;
    public string Name { get { return _name; } }

    [SerializeField]
    private int _price;
    public int Price { get { return _price; } }

}

Il nous faut ensuite créer un nouveau ScriptableObject qui va contenir une liste de tous nos Assets de Données. Pourquoi ? Cet Asset va contenir une référence vers tous les Assets de Donnée de votre projet. Au lieu d’avoir à gérer par la suite plein de liens, vous n’en aurez qu’un seul :

public class MyAssetsData : ScriptableObject
{
    public ObjectData[] Database;
}

Notre conteneur de données est là, nous avons donc un tableau avec tous les Assets de Données dans un autre Asset de Données. Mais c’est encore trop archaïque. On va donc rajouter une dernière couche par dessus pour gérer tout ça.

C’est là que notre gestionnaire de données entre en jeu. C’est ce que nous utiliserons ensuite pour :

  • Rafraîchir la liste des Assets de Données
  • Charger simplement les Assets de Données dans vos scripts

Voilà le source de ce gestionnaire, l’explication est donnée plus bas :

using System.Collections.Generic;
#if UNITY_EDITOR
using System.IO;
using UnityEditor;
#endif
using UnityEngine;

public class MyAssetDatabase
{
    private const string DatabasePath = "Assets/MyAssetDatabase.asset"; // Chemin vers votre base de données
    private const string DataObjectPath = "/Data/"; // Chemin vers le répertoire d'Assets de Données

    private static Dictionary<string, ObjectData> _database;
    private static bool _initialized = false;

    public static ObjectData Get(string id)
    {
        if (!_initialized)
        {
            InitializeDatabase();
        }
        return _database[id];
    }

    public static void InitializeDatabase()
    {
        MyAssetsData assetsData = AssetDatabase.LoadAssetAtPath<MyAssetsData>(DatabasePath);
        _database = new Dictionary<string, ObjectData>();
        for (var i = 0; i < assetsData.Database.Length; i++)
        {
            _database.Add(assetsData.Database[i].Id, assetsData.Database[i]);
        }
        _initialized = true;
    }

#if UNITY_EDITOR
    [MenuItem("Tools/Refresh Database")]
    public static void RefreshDatabaseAsset()
    {
        var paths = Directory.GetFiles(Application.dataPath + DataObjectPath, "*.asset", SearchOption.AllDirectories);
        var assets = new ObjectData[paths.Length];
        for (var i = 0; i < paths.Length; i++)
        {
            var path = paths[i].Replace(Application.dataPath, "Assets");
            Debug.Log("Path to load : " + path);
            assets[i] = AssetDatabase.LoadAssetAtPath<ObjectData>(path);
            Debug.Log(assets[i]);
        }


        var assetDatabase = new MyAssetsData();
        assetDatabase.Database = assets;
        AssetDatabase.CreateAsset(assetDatabase, DatabasePath);
        AssetDatabase.Refresh();
    }
#endif
}

Voyons d’abord la partie concernant le rafraîchissement de la liste des Assets. Notre manager va dynamiquement générer la base de données à partir de la liste de tous les Assets de Données que vous aurez créé (voir le paramétrage des répertoires en haut de la classe).

On câble cette action derrière un bouton exposé dans le menu principal. Pour plus d’infos sur la création de menus, lisez cet article. On parcourt la totalité des fichiers dans le répertoire et les répertoires enfant. Petite modification sur le chemin pour obtenir un chemin relatif au projet et ensuite on charge l’Asset de Données pour l’ajouter au tableau.

Une fois le tableau construit, on génère le fichier de base de données.

Attention : n’oubliez pas de masquer le code utilisé dans l’éditeur avec les instructions préprocesseur. Idem pour l’import du namespace « UnityEditor ». Sinon, vous aurez des soucis de compilation !

Enfin, la seconde partie concernant l’utilisation de ce manager. On expose une méthode « Get » qui va renvoyer l’Asset de Données correspondant à l’ID. Mais avant, il faut vérifier si le manager a bien chargé l’Asset de base de données. On transforme alors notre liste en un dictionnaire pour pouvoir charger les éléments directement depuis leur identifiant. Et voilà le travail.

Modifions enfin notre script Chest pour utiliser cette nouvelle base de données :

[SerializeField]
private string _idObject;

public ObjectData OpenChest()
{
    var dataObject = MyAssetDatabase.Get(_idObject);
    Debug.Log("Le joueur a trouvé " + dataObject);
    return dataObject;
}

La dépendance vers l’Asset de Données a disparue. Il ne reste qu’un lien via son identifiant.

Avantages :

  • Votre gameplay est décorrelé de vos données
  • Possibilité de gérer des objets dynamiquement
  • Facilité d’utilisation

Inconvénients :

  • Il faut sécuriser les appels dans le cas où on tenterait de charger un objet dont l’identifiant n’est pas trouvé dans la base de données
  • Ajoute de la complexité quant à la gestion des Assets de Données
  • Tous les identifiants doivent être uniques (ajouter ce contrôle lors de la construction de la base de données)

Pour aller plus loin

Hop, vous savez maintenant créer et utiliser des Assets de données pour votre projet. C’est simple, totalement intégré à Unity et extensible à souhait. C’est pas chouette ça ?

Pour stocker les données d’utilisation du jeu (progression, argent, etc), lisez cet article sur les PlayerPrefs. Vous y apprendrez comment stocker vos données de progression sans avoir à coder vos propres outils de sauvegarde.

A moi de vous demander une faveur maintenant ! Cet article vous a appris quelque chose ? Partagez-le à un ami pour qu’il puisse lui aussi gérer facilement des Assets de données. Je compte sur vous !


Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *