Comment créer des Assets de données dans Unity3D

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

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 ObjectData de votre projet. Au lieu d’avoir à gérer par la suite plein de liens, vous n’en aurez qu’un seul :

[CreateAssetMenu(fileName = "NewDatabase", menuName = "Base de données")]
public class MyAssetsDatabase : ScriptableObject
{
    public ObjectData[] dataAsArray;
}

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. Une liste de ScriptableObjects dans un ScriptableObject. Mais c’est encore trop archaïque. On va donc rajouter une dernière couche par dessus pour gérer tout ça.

Vous pouvez améliorer ce ScriptableObject pour lui donner un comportement uniquement disponible dans l’éditeur. Ce comportement fera le travail pour vous : lister les ObjectData de votre projet et les ajouter dans un tableau.

Ci-dessous le source modifié de MyAssetDatabase pour répondre à ce besoin :

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

[CreateAssetMenu(fileName = "NewDatabase", menuName = "Base de données")]
public class MyAssetDatabase : ScriptableObject
{
  private const string DataObjectPath = "/Data/"; // Chemin vers le répertoire d'Assets de Données

  public ObjectData[] dataAsArray;

  public ObjectData Get(string id)
  {
    for (var i = 0; i < dataAsArray.Length; i++)
    {
      if (dataAsArray [i].Id.Equals (id))
        return dataAsArray [i];
    }
    return null;
  }
    
  #if UNITY_EDITOR
  // Charge la base de données
  [ContextMenu("Recharger la base de données")] // Permet de créer un menu contextuel sur l'Asset
  public void FillDatabase()
  {
    var paths = Directory.GetFiles(Application.dataPath + DataObjectPath, "*.asset", SearchOption.AllDirectories);
    dataAsArray = 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);

      var asset = AssetDatabase.LoadAssetAtPath<ObjectData> (path);
      dataAsArray[i] = asset;
    }

  }
  #endif
}

La base de données va avoir un tableau des Assets qu’il faut remplir. C’est ce que fait la méthode FillDatabase. Elle va parcourir tous les fichiers dans le répertoire de données définie par DataObjectPath, les charger et les ajouter dans le tableau.

Notez que la méthode FillDatabase utilise des outils de l’éditeur. Il ne faut donc pas que ces élémens de code soient exportés dans votre build final. C’est pour cela qu’elle est encadrée d’instructions préprocesseur. L’avantage, c’est que l’Asset MyAssetDatabase, lui, sera généré et enregistré dans votre projet. Vous pourrez donc l’utiliser par la suite sans problème.

On câble cette action dans le menu contextuel de notre asset pour y accéder facilement (dans la roue crantée de l’inspecteur). Pour plus d’infos sur la création de menus, lisez cet article.

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. Et voilà le travail.

La dernière étape est de modifier notre script Chest pour utiliser cette base de données. Comme vous l’avez vu, c’est un ScriptableObject. Il faut donc exposer une nouvelle variable pour indiquer quelle est la base de données à utiliser :

[SerializeField]
private MyAssetDatabase _database;

[SerializeField]
private string _idObject;

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

La dépendance directe vers l’ObjectData a disparue. Nous avons à la place une liaison vers la base de données qui elle, contient tous les objets, ainsi que l’ID à charger.

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 !

  • Sézille dit :

    Bonjour,
    Cet article m’a fait rêvé et je souhaiterai pouvoir l’utiliser pour la gestion de bases de données dans des cours sur Unity 3D.
    Je souhaiterais donc savoir si vous me permettez d’utiliser vos travaux ? (en citant la source complète, à savoir votre site et par extension, vous)
    Merci grandement d’offrir votre savoir à la communauté !
    Bonne continuation.

    • EspritUnity dit :

      Bonjour et merci ! Vous pouvez tout à fait utiliser cet article pour vos cours Unity3D avec citation de la source (un lien vers cet article serait parfait).

  • Joël dit :

    Bonjour et merci pour votre article !

    Je me sers de votre système d’asset sur un petit projet de jeu et il se trouve que ce n’est pas possible de compiler avec ces fichiers puisque « AssetDatabase » dans la class « MyAssetDatabase » a besoin de a directive « using UnityEditor; ».
    Auriez vous une solution à proposer pour résoudre ce problème ?

    Cordialement.

    • Alex Frêne dit :

      Salut Joël.

      Remarque très pertinente, ce sont des outils de l’éditeur qui ne peuvent pas être exportés. J’ai modifié l’article pour que la solution soit utilisable dans le build final. Il y a quelques petits changements mais le principe reste dans les grandes lignes le même.

  • Antheus dit :

    Bonjour, je me posais une question.
    si, par exemple, on voulait donner au joueur la possibilité de créer une arme avec ses caractéristique / nom ect… comment pourrait t’on la stoquer ?
    cette technique ne semble pas permettre de servir de banque de stockage… si ?

    • Alex Frêne dit :

      Hello !

      Dans ce cas précis, non ça ne sera pas possible d’utiliser des ScriptableObjects car tu veux manipuler des données dynamiques créées au runtime par ton joueur. Les données d’un ScriptableObject doivent être préparées et enregistrées en tant qu’asset du projet pour être utilisées ensuite (après compilation).

      Mais ce que tu veux faire n’est pas impossible. Il te faudra enregistrer les infos à la main dans un fichier par exemple, ou dans les PlayerPrefs. Le principe est assez simple :
      – Créer une classe que représente les données de ton arme
      – La déclarer comme sérialisable
      – Sérialiser l’objet que tu veux enregistrer sous forme d’une chaine de caractères
      – Enregistrer la chaine avec le système de ton choix

      Pour charger les données, c’est le même chemin, mais dans le sens inverse. Charger > Désérialiser. C’est un concept que je détaille dans cet article : https://www.esprit-unity.fr/enregistrer-des-donnees-avec-playerprefs/

      Happy Gamedev !

  • >