Scriptable Object : un asset de stockage sur-mesure pour Unity 3D

Un Scriptable Object est une classe disponible dans Unity 3D qui vous permet de créer vos container de données sur mesure pour votre projet de jeu ou d’application.

En somme, vous allez définir une classe qui hérite de ScriptableObject, en spécifiant les données à stocker. Puis, vous pourrez créer des instances de ce ScriptableObject, qui seront visible comme des fichiers présents dans le répertoire de votre projet.

Un ScriptableObject n’est pas une classe directement ajoutée comme composant, mais on peut adresser un tel objet dans un composant, et interroger les valeurs qu’il contient.

C’est extrêmement pratique pour : du prototypage rapide, l’organisation de certains assets (fichiers audio ou sprites), et de manière générale tout ensemble de données que vous voudriez garder de manière persistance.

Création d’une classe définissant un Scriptable Object

Pour l’exemple nous allons créer une classe CharacterDetails qui stockera les données d’un personnage.

Dans votre projet, créez un nouveau script C# dans votre répertoire de scripts. Appelons-le CharacterDetails. On ouvre ensuite le fichier et on efface les méthodes par défaut (Start() et Update(), qui dépendent de MonoBehaviour).

Et modifions l’héritage de ChracterDetails pour qu’il dépende de ScriptableObject et non plus de MonoBehaviour. La classe devrait alors contenir le code suivant :

using UnityEngine;

public class CharacterDetails : ScriptableObject
{
    
}

Nous pouvons ensuite définir les différentes données que nous voulons stocker pour notre classe. Attention, pour pouvoir être utilisées dans l’inspecteur, les champs doivent être de types Serializables et configurés comme étant visibles dans l’inspecteur (cf. article sur SerializeField / HideInInspector et la serialization).

Par exemple, pour notre classe CharacterDetails, nous pouvoir y mettre des champs de type string, un Sprite d’Unity, un enum créé sur mesure, des entiers, et même une collection.

public class CharacterDetails : ScriptableObject
{
    public string Name;
    public Sprite Portrait;
    public enum CharacterRace { Human, Elf, Dwarf, Orc, Halfling };
    public CharacterRace Race;
    public int Gold = 0;
    public List<string> Inventory;
    public bool HasItem => Inventory.Count > 0;
}

Ici, tout sera présenté dans l’inspecteur, hormis HasItem puisque c’est une propriété, non serializable. Ça ne l’empêchera pas d’être accessible via le code, car un ScriptableObject reste un objet avant tout !

Cela va nous donner un objet qui ressemblera à ceci :

Exemple de ScriptableObject de classe Character Details

Il reste un dernier détail à régler : faire apparaître un élément dans les menus d’Unity pour créer ce type d’objet. Nous allons réaliser ça avec l’attribut CreateAssetMenu. Cet attribut est placé avant la définition de la classe et contient trois propriétés :

  1. fileName : le nom par défaut d’un fichier créé avec cette classe
  2. menuName : le chemin dans les menus, avec des sous-menus possibles séparés par des /
  3. order : la position dans le menu (quel que soit l’ordre, ça sera toujours au-dessus des autres assets d’Unity : l’ordre est en fonction des autres éléments indiqués avec CreateAssetMenu.

Par exemple, avec ce code :

[CreateAssetMenu(fileName = "NewCharacter", menuName = "Character/CharacterDetails", order = 1)]
public class CharacterDetails : ScriptableObject
{
    // ...
}

Nous aurons un élément de menu comme ceci, accessible aussi bien dans la fenêtre Projet que dans le menu Asset > Create.

Élément du menu Unity créé avec l’attribut CreateAssetMenu
Élément de la fenêtre projet Unity créé avec l’attribut CreateAssetMenu

Avec ce menu Create, vous pourrez créer directement des fichiers CharacterDetails dans votre projet (ils auront par défaut un nom NewCharacter), tel que choisi dans la paramètre fileName. Ce sont des fichiers au format .asset.

Notez que vous pouvez vous astreindre à ajouter une sorte de sous-extension pour vous y retrouver dans l’explorateur Windows. Il vous suffit d’indiquer dans l’attribut fileName = "NewCharacter.characterDetails.asset"

Assurez-vous d’ajouter .asset à la fin du nom de fichier, sinon Unity créée un fichier .characterDetails et ne le reconnaît plus. En procédant

Dernière astuce : vous pouvez ajouter une icône à la classe du ScriptableObject, ça vous permettra de la distinguer des autres dans l’inspecteur. Sélectionnez votre classe C#, et dans l’inspecteur, cliquez sur l’icône de script C#, sélectionnez « Other… » et choisissez votre icône.

Personnalisation d’une icône sur un ScriptableObject

Utiliser un Scriptable Objet et récupérer ses champs

Un Scriptable Objet est utilisable comme n’importe quelle instance de classe. Dans notre script de test, nous allons par exemple définir un CharacterDetails, puis récupérer des données, effectuer des test sur celles-ci, etc. :

public class ScriptableObjectTest : MonoBehaviour
{
    [SerializeField] private CharacterDetails mainCharacter;
    void Start()
    {
        if(mainCharacter != null)   // Le code suivant ne se déclenche pas si mainCharacter n'est pas assigné dans l'inspecteur
        {
            Debug.Log($"Welcome {mainCharacter.Name} (race: {mainCharacter.Race}).");
            Debug.Log($"{mainCharacter.Name} " +
                (mainCharacter.Gold < 5 ?
                "is poor." : $"has {mainCharacter.Gold} gold pieces."));
            if (mainCharacter.HasItem)  // On ne montre l'inventaire que s'il y a au moins un item.
            { 
                Debug.Log($"Here is {mainCharacter.Name}'s inventory:");
                foreach(string item in mainCharacter.Inventory)
                {
                    Debug.Log($"- {item}");
                }
            }
        }
    }
}

Prenons bien soin d’assigner un objet CharacterDetails dans l’inspecteur, depuis les objets présents dans le projet.

Scriptable Object assigné dans l’inspecteur.

En lançant l’application pour tester notre jeu, nous aurons par exemple :

Welcome Adler (race: Elf).
Adler is poor.
UnityEngine.Debug:Log (object)
ScriptableObjectTest:Start () (at Assets/Source/Scripts/ScriptableObjectTest.cs:26)

Si vous assignons dans « Main Character » le personnage suivant, avec plein de valeurs intéressantes :

La sortie obtenue est la suivante :

Welcome Starga (race: Dwarf).
Starga has 6523 gold pieces.
Here is Starga's inventory:
- Hammer
- Shield
- Helmet
- Book
UnityEngine.Debug:Log (object)
ScriptableObjectTest:Start () (at Assets/Source/Scripts/ScriptableObjectTest.cs:26)

À noter : dans l’absolu, il est possible de créer une instance d’un Scriptable Object pendant le déroulement du jeu et de la sauvegarder (ce n’est pas un système natif dans ScriptableObject, il faut chercher du côté de JsonUtility). Mais ce n’est pas le but, ce type d’objet est vraiment conçu pour stocker une instance d’objet dans un fichier du projet. Pensez-les comme des fichiers de configuration et privilégiez d’autres manières pour sauvegarder, comme la sauvegarde dans un fichier XML ou JSON.

Scriptable Object étendu, avec des classes serializable

Après avoir découvert comme le Scriptable Object est pratique dans Unity, vous aurez peut-être envie de créer des Scriptable Objects un peu plus poussées, notamment avec des collections d’autres classes.

Pour cela, il n’est pas nécessaire de créer des Scriptable Objects imbriqués les uns dans les autres. Il vous suffit de créer la classe contenant les ensemble de champs que vous voulez ajouter dans une collection. Cette classe n’héritera de rien, mais il faut lui ajouter l’attribut [System.Serializable] pour qu’elle puisse être prise en compte dans le Scriptable Object.

Niveau de réputation (string et int) avec un dictionnaire

Pour notre personnage ci-dessus, si nous voulons avoir une liste de niveau de réputation selon les lieux, nous pouvons créer la classe Reputation ainsi, contenant une ville et un niveau de réputation de -5 à 5 :

[System.Serializable]
public class Reputation
{
    public string City;
    [Range(-5,5)]
    public int Level;
}

Et dans notre Scriptable Objet Character Details, nous pouvons ajouter un array de cette classe Reputation :

public Reputation[] ReputationInCities;

Si nous ouvrons une instance de Scriptable Object présente dans les fichiers de notre projet, nous aurons l’array présenté ainsi, et nous pouvons donc régler le niveau de réputation pour chacune des villes.

Pour simplifier l’interrogation, nous pouvons créer par exemple un dictionnaire. Ce dictionnaire ne sera pas présenté dans l’Inspecteur vu que c’est un type non-Serializable. En revanche, nous pouvons le construire au moment où l’objet est appelé, grâce à la méthode « OnEnable()« , qui est appelée lorsque l’objet est chargé (nous aurions pu aussi le faire dans Awake(), méthode appelée au démarrage du jeu).

[SerializeField] private Reputation[] reputationInCities;   
// Disponible uniquement dans l'inspecteur, n'est pas public dans la classe
public Dictionary<string, int> Reputations;
// Dictionnaire permettant d'obtenir la réputation "Value" pour la ville "Key"

private void OnEnable()
{
	Reputations = new Dictionary<string, int>();
	foreach (Reputation reputation in reputationInCities)
	{
		if (!Reputations.ContainsKey(reputation.City))
		{
			Reputations.Add(reputation.City, reputation.Level);
		}
	}
}

Ici, nous avons rendu juste le dictionnaire public et accessible en interrogeant l’objet. Le tableau avec les Reputation est passé en private, avec [SerializeField] pour qu’il reste accessible dans l’inspecteur (voir ci-dessous l’astuce pour renommer le nom d’un champ sans perdre ce qu’il contient).

Une fois ceci fait, si nous interrogeons le dictionnaire Reputations, nous pourrons récupérer toutes les réputation connues :

foreach (KeyValuePair<string, int> entry in mainCharacter.Reputations)
{
    Debug.Log($"Reputation in {entry.Key} is {entry.Value}");
}
// Sortie console :
// Reputation in Tavern Town is 0
// Reputation in West Mines is 3
// Reputation in Elveswood is -2

Et nous pouvons même évaluer si l’entrée existe dans le dictionnaire, et réagir en conséquence (voir cet article sur les collections, qui aborde les Dictionnaires C#) :

void OnEnterCity(string cityName)
{
	if(mainCharacter.Reputations.TryGetValue(cityName, out int reputation))
	{
		// L'humeur des villageois dépend de la réputation trouvée pour mainCharacter.
		if (reputation > 2)
		{
			Villagers.SetMood("Happy");
		}
		else if (reputation < -2)
		{
			Villagers.SetMood("Aggressive");
		}
		else
		{
			Villagers.SetMood("Neutral");
		}
	}
	else
	{
		// mainCharacter n'a pas de réputation, les villageois le saluent.
		Villagers.GreetCharacter();
	}
}

Liste d’images à afficher dans l’UI et de sons à lire, récupérées via des méthodes

Cet exemple va illustrer un autre aspect des scriptable object : la possibilité d’inventoriser des assets (comme des images ou des sons) directement dans un objet.

Nous voulons créer un ensemble d’objets « MoodPresentation« , contenant : un enum décrivant l’humeur, une image Sprite à afficher sur l’écran, et un son (AudioClip) à lire. Typiquement, ça peut servir pour un système de dialogue.

Nous créons la classe suivante :

public enum Mood { Neutral, Happy, Angry, Sad };
// L'enum Mood est en-dehors de la classe pour permettre son utilisation dans toute l'application.
[System.Serializable]
public class MoodPresentation
{
    public Mood Mood;
    public Sprite Face;
    public AudioClip Sound;
}

Et dans notre classe CharacterDetails, nous ajoutons les champs suivants :

[SerializeField] private MoodPresentation defaultPresentation;
[SerializeField] private MoodPresentation[] moodPresentations;

Nous avons une version par défaut de l’objet, ainsi qu’un tableau qui peut contenir toutes les variations selon la valeur de mood. Ces champs sont volontairement en « private » avec l’attribut SerializeField pour pouvoir les configurer dans l’inspecteur.

Nous pouvons donc, dans l’inspecteur, définir les Sprite et les AudioClip pour les différentes humeurs. Sachant que « defaultPresentation » contient les valeurs de repli, au cas où il n’y a pas de champ existant pour l’humeur en question :

Exemple de classe MoodPresentation dans notre Object Scriptable

Dans la classe CharacterDetails, ajoutons une méthode qui récupère la Sprite en prenant la valeur Mood en argument.

Si l’argument mood est la valeur proposée dans defaultPresentation, ou si le tableau moodPresentations ne contient pas l’humeur voulue, alors c’est la valeur de defaultPresentation qui est envoyée.

public Sprite GetFaceForMood(Mood mood)
{
	if (mood != defaultPresentation.Mood)
	{
		foreach (MoodPresentation presentation in moodPresentations)
		{
			if (presentation.Mood == mood)
			{
				if (presentation.Face != null)
				{
					return presentation.Face;
				}
			}
		}
	}
	return defaultPresentation.Face;
}

Ainsi, du côté de notre système de dialogue ou autre système ayant besoin d’afficher une sprite du personnage, il suffira de faire appel à cette méthode CharacterDetails.GetFaceForMood(Mood.Angry) pour obtenir la sprite du personnage énervé, par exemple.

Il ne nous reste plus qu’à faire la même chose pour les AudioClip, en prenant en compte les situations où aucun AudioClip n’est renseigné.

Cette approche et la précédente peuvent être combinées, ainsi, vous pouvez créer un dictionnaire associant les Mood à des Sprite.

Astuce : comment changer le nom d’un champ sans perdre les données stockées ?

Il vous arrivera probablement de vouloir changer le nom d’un champ, par exemple parce que vous récupérez ses valeurs différemment ou pour résoudre des ambigüités. Cependant, si vous changez le nom du champ, sa valeur risque d’être perdue dans tous les objets, vu qu’il ne sera plus reconnu ! Et si vous avez une solution qui repose sur de nombreux Scriptable Objet, c’est une perte de données conséquente que vous risquez.

Il y a une méthode simple pour y remédier : tout d’abord, ajoutez le namespace Unity.Serialization dans votre Scripted Object…

using UnityEngine.Serialization;

Puis ajoutez l’attribut [FormerlySerializedAs("AncienNom")] devant le champ que vous voulez renommer. Ainsi, ses valeurs seront conservées.

// Avant :
    public int Gold = 0;

// Après
    [FormerlySerializedAs("Gold")]
    public int Money = 0;

Une fois que la solution a été recompilée (et de préférence, après avoir été vérifier dans les objets que le changement a bien été fait), vous pouvez enlever cet attribut.

Notez que cet attribut ne concerne pas que les ScriptableObject : tout champ renseigné dans l’inspecteur est concerné. Ainsi, si vous avez de nombreux objets configurés directement dans l’inspecteur, vous pouvez renommer leurs champs et conserver ce que vous avez saisi grâce à cet attribut.

Quelques exemples d’utilisation des Scriptable Objects

Vous l’avez compris, ces objets programmables sont une brique de construction extrêmement utile dans Unity 3D, et une fois que vous aurez pris l’habitude de les utiliser, vous aurez du mal à vous en passer !

Voici quelques exemples de situations où ils peuvent être pratiques :

  1. Stocker des données à propos d’un niveau (musique, nom du niveau, nom de la scène…) accessible aussi bien depuis le niveau que depuis le menu principal.
  2. Disposer d’un Atlas centralisé de ressources communes (bruitages, sprites, etc.), ce qui permet d’y accéder via une variable statique du GameManager (par ex : GameManager.SoundIndex.GameOverSound).
  3. Enregistrer la configuration d’un ennemi, d’un véhicule ou autre dans un fichier, pour pouvoir tester plusieurs configurations alternativement, ou très rapidement assigner des comportements différents.
Un scriptable Objet peut contenir la configuration pour une IA d’un ennemi, permettant ainsi de très rapidement définir des comportement-type via ces assets.
  1. Définir différents Sprites ou Sons pour un personnage pour le système de dialogue, selon son humeur (exemple à la précédnete section).
  2. Stocker les sprites représentant les contrôles gamepad ou les contrôles clavier/souris, et changer leur représentation sur l’interface quand on passe de l’un à l’autre.
  3. Proposer plusieurs versions de la liste des niveaux du jeu, selon que l’application en cours est la démo, la version complète ou une version avec des DLC.

One thought on “Scriptable Object : un asset de stockage sur-mesure pour Unity 3D

Commentaires désactivés