Illustration en flat design représentant le chemin d'une scène Unity vers un disque dur, puis un retour vers la scène Unity. Un cadre vert entoure la première partie du chemin qui correspond à la sauvegarde.

Comment sauvegarder une scène Unity avec C# ? (1/2)

Imaginez un jeune créateur passionné qui se lance dans le développement d’un jeu vidéo. C’est le cas de mon petit frère qui, malgré son enthousiasme, manque encore d’expérience en programmation. L’une des fonctionnalités essentielles qu’il souhaite intégrer est la possibilité pour les joueurs de sauvegarder leur progression lorsqu’ils quittent le jeu, ou lorsqu’un événement précis survient.

Ne sachant pas comment développer cette fonctionnalité, nous allons lui trouver une solution de sauvegarde simple et sécurisée en C# qu’il pourra intégrer facilement à Unity, et cela même si cette fonctionnalité n’a pas été anticipée au début du projet.

Cet article présente et implémente la première partie de la solution visant à sauvegarder une scène Unity dans un fichier. La restauration depuis ce même fichier sera présentée dans un prochain article.

Le défi majeur de la sauvegarde de scènes Unity

La complexité de cette fonctionnalité réside principalement dans le volume d’informations à sauvegarder et le défi que représente la restauration d’une scène 3D.

Pour illustrer ma solution, j’ai développé un mini jeu appelé « Kenny Killer Game ». Le but de ce jeu est de détruire l’école élémentaire de South Park, protégée par une armée de Kenny. Tant qu’au moins un Kenny est vivant, l’école est protégée par un bouclier. Il faut donc éliminer tous les Kenny de la carte avant de pouvoir attaquer l’école. J’admets que l’idée est un peu violente, mais elle reste fidèle à l’esprit décalé de la série.

Comme dans tout jeu vidéo, nous pouvons classer les éléments d’une scène selon deux catégories :

  • Les éléments statiques : Ces éléments sont instanciés à l’initialisation de la scène, peuvent évoluer dans le temps, mais restent présent jusqu’à la fin de la partie. Pour « Kenny Killer Game », cela inclut l’école et le joueur, pour lesquels les points de vie évoluent ;
  • Les éléments dynamiques : Ces éléments sont instanciés dynamiquement en fonction du déroulement de la partie. Pour « Kenny Killer Game », il s’agit des personnages Kenny, qui sont créés régulièrement et disparaissent lorsqu’ils sont éliminés.

Sauvegarder un état plutôt que la scène Unity

Que le jeu soit simple ou très complexe, dans les deux cas, une scène Unity est composée de plusieurs dizaines d’objets pour lesquels de multiples instances de classe coexistent dans des états très différents.

Unity ne propose aucune solution pour sérialiser une scène et la restaurer dans son état d’arrêt. Il est certains qu’en créant notre propre système, nous aurons très peu de chance d’aboutir à une solution capable de sauvegarder et restaurer parfaitement une scène. L’objectif est donc de trouver un compromis technique permettant au jeu de relancer la scène dans un état très proche de celui d’arrêt.

Pour cela, il est d’usage de créer un état fixe composé de données primitives (int, string, array, etc.) que nous pouvons sérialiser vers un format de données facilement exploitable. En d’autres termes, pour un objet donné de la scène Unity, plutôt que sauvegarder l’objet lui-même, nous allons extraire les données le représentant. A la relance de la partie, ces données doivent permettre à l’objet de se restaurer.

Par exemple, pour mon mini jeu, j’ai identifié :

  • Le joueur : un objet contenant le nombre de points de vie ;
  • L’école élémentaire : un objet contenant le nombre de points de vie ;
  • Kenny : un objet contenant la position dans la scène et son état (en vie, en train de mourir).

L’état complet de la scène peut donc être considéré comme un agrégat des états de chaque objet qui la compose.

Mise en place de la solution

Conception du système de sauvegarde

La solution que je vous propose s’implémente à partir du diagramme de classe suivant :

classDiagram
    MonoBehaviour <|-- StatePersistenceService
    MonoBehaviour <|-- StateComponent
    StatePersistenceService <-- StateComponent: register(this)

    class MonoBehaviour {
        ...
    }

    class StatePersistenceService {
        -List~StateComponent~ _components
        -bool _stateLoaded
        -StatePersistenceService _Instance$
        +bool Restore$
        +Register(StateComponent component) bool
        +Unregister(StateComponent component) void
        +LoadBackup() void
        +Persist() void
    }

    class StateComponent {
        -StatePersistenceService Service
        #Restore(StatePersistenceService service) void*
        +Snapshot(StatePersistenceService service) void*
    }

La classe StatePersistenceService doit être instanciée une seule fois dans la scène, permettant ainsi à tous les GameObject auxquels nous attachons la classe StateComponent de travailler avec cette unique instance (Instance). Grâce à ses méthodes Register et Unregister, chaque StateComponent peut s’inscrire ou se désinscrire automatiquement du service. Lorsque la méthode Persist du service est appelée, la méthode Snapshot de chaque StateComponent l’est également. Cela permet ainsi aux composants enregistrés de fournir leur état avant la sauvegarde des données.

Vous remarquerez que les méthodes Restore et Snapshot sont abstraites. Cette déclaration est importante pour forcer leur définition. Les classes filles seront contrainte de déclarer leur propre comportement de génération d’état et de restauration.

Implémentation des fondations

Le modèle présenté ci-dessus est incomplet pour être intégré dans Unity, nous le finaliserons après l’implémentation des éléments fondamentaux suivants :

  • La classe StatePersistenceService ;
  • La classe StateComponent.

Définition de la classe StatePersistenceService

// /Assets/Src/Persistence/StatePersistenceService.cs
using System.Collections.Generic;

using UnityEngine;


namespace Lavoiedudev.Game.Persistence
{
    /// <summary>
    /// Service de persistence de l'état de la partie.
    /// </summary>
    public class StatePersistenceService : MonoBehaviour
    {
        private static StatePersistenceService _Instance;
        /// <summary>
        /// Référence à l'instance unique de la scène.
        /// <summary>
        public static StatePersistenceService Instance { get => _Instance; }

        /// <summary>
        /// Flag indiquant si la scène doit être restaurée.
        /// </summary>
        public static bool Restore = false;

        /// <summary>
        /// Liste des composants qui doivent être sauvegardés.
        /// </summary>
        private List<StateComponent> _components = new List<StateComponent>();

        /// <summary>
        /// Flag propre à l'instance permettant d'identifier si une sauvegarde a été chargée ou non.
        /// </summary>
        private bool _stateLoaded;

        public void Awake()
        {
            _Instance = this;

            if (Restore)
            {
                LoadBackup();
            }
            else
            {
                _stateLoaded = false;
            }
        }

        /// <summary>
        /// Chargement de l'état à partir d'un fichier.
        /// </summary>
        private void LoadBackup()
        {
            // TODO: lecture du fichier

            _stateLoaded = true;
        }

        /// <summary>
        /// Inscription d'un composant en tant qu'élément à sauvegarder.
        /// </summary>
        /// <param name="component">La référence au composant à sauvegarder</param>
        /// <returns>true lorsque </returns>
        public bool Register(StateComponent component)
        {
            if (!_components.Contains(component))
            {
                _components.Add(component);
            }

            return _stateLoaded;
        }

        /// <summary>
        /// Désinscription du composant car il ne nécessite plus d'être sauvegardé.
        /// </summary>
        /// <param name="component">La référence au composant à supprimer</param>
        public void Unregister(StateComponent component)
        {
            if (_components.Contains(component))
            {
                _components.Remove(component);
            }
        }

        /// <summary>
        /// Méthode agrégeant les états des éléments à sauvegarder avant de l'écrire dans un fichier.
        /// </summary>
        public void Persist()
        {
            foreach (StateComponent component in _components)
            {
                component.Snapshot(this);
            }

            // TODO: Sauvegarde dans un fichier
        }
    }
}
1

Le champ _stateLoaded peut sembler redondant. Cependant, l’attribut Restore étant statique, cet attribut d’instance est important pour éviter les interférences dans le scope global. Par ailleurs, avoir cette information au niveau de l’instance permet d’obtenir une information supplémentaire sur les échecs potentiels lors du chargement depuis un fichier.

Définition de la classe StateComponent

// /Assets/Src/Persistence/StateComponent.cs
using UnityEngine;


namespace Lavoiedudev.Game.Persistence
{
    /// <summary>
    /// Composant responsable de la persistance de l'état d'une entité.
    /// </summary>
    public abstract class StateComponent : MonoBehaviour
    {
        public void Start()
        {
            if (StatePersistenceService.Instance.Register(this))
            {
                // Les données doivent être restaurées
                Restore(StatePersistenceService.Instance);
            }
        }

        public void Destruct()
        {
            StatePersistenceService.Instance.Unregister(this);
        }

        /// <summary>
        /// Méthode permettant de restaurer l'état du composant à partir du service.
        /// </summary>
        /// <param name="service">L'instance du service contenant l'état chargé.</param>
        protected abstract void Restore(StatePersistenceService service);

        /// <summary>
        /// Méthode permettant de créer et pousser l'état du composant au service.
        /// </summary>
        /// <param name="service">L'instance du service qui recevra la mise à jour de l'état.</param>
        public abstract void Snapshot(StatePersistenceService service);
    }
}

Actuellement, le système est inutilisable sans implémenter chaque classe fille. Le comportement défini permet d’intégrer la logique suivante dans le cycle de vie de Unity :

  1. Lors du Awake, l’unique StatePersistenceService partage sa référence globalement et charge les données depuis un fichier si Restore est true.
  2. Lors du OnEnable, au lancement de la scène ou après un délai (instanciation dynamique), les composants s’enregistrent automatiquement via l’instance globale du service.
  3. Lors du Destruct du GameObject, à l’arrêt de la scène ou à la destruction d’un objet, l’instance StateComponent se désinscrit du service car elles deviennent inutiles.

Des données structurées pour définir un état

Avant de continuer l’implémentation des classes filles, nous devons définir les structures de données représentatives de la scène. Les informations pertinentes ont été décrites précédemment, nous définissons donc les classes de données correspondantes suivantes :

  • La classe PlayerData : elle contient l’état actuel du joueur ;
  • La classe SchoolData : elle contient l’état actuel de l’école élémentaire ;
  • La classe KennyData : elle contient l’état d’une instance de Kenny ;
  • La classe GameData : elle représente la scène.

Définition de la classe PlayerData

// /Assets/Src/Persistence/Data/PlayerData.cs
using System;


namespace Lavoiedudev.Game.Persistence.Data
{
    [Serializable]
    public class PlayerData
    {
        public int Life;

        public PlayerData(int life)
        {
            Life = life;
        }
    }
}

Définition de la classe SchoolData

// /Assets/Src/Persistence/Data/SchoolData.cs
using System;


namespace Lavoiedudev.Game.Persistence.Data
{
    [Serializable]
    public class SchoolData
    {
        public int Life;

        public SchoolData(int life)
        {
            Life = life;
        }
    }
}

Définition de la classe KennyData

// /Assets/Src/Persistence/Data/KennyData.cs
using System;

using UnityEngine;


namespace Lavoiedudev.Game.Persistence.Data
{
    [Serializable]
    public class KennyData
    {
        public KennyState State;

        public Vector3 Position;

        public KennyData(KennyState state, Vector3 position)
        {
            State = state;
            Position = position;
        }
    }
}

Définition de la classe GameData

// /Assets/Src/Persistence/Data/GameData.cs
using System;
using System.Collections.Generic;

using UnityEngine;


namespace Lavoiedudev.Game.Persistence.Data
{
    [Serializable]
    public class GameData
    {
        public SchoolData School = null;

        public PlayerData Player = null;

        [SerializeField] <1>
        public List<KennyData> Kennys = new List<KennyData>();
    }
}
1

L’attribut [SerializeField] est nécessaire sur les champs de type List<T> pour pouvoir les serialiser via l’utilitaire JsonUtility.

Comme vous pouvez le voir, nous utilisons plusieurs classes différentes pour créer une structure de données hiérarchisée. Cette hiérarchie est conçue pour partager les informations en sous-structures représentatives des objets 3D qui composent la scène.

Cette approche simplifie le traitement des données en ne manipulant qu’une structure par type d’entité. Vous remarquerez d’ailleurs que les éléments statiques présents du début à la fin de la partie, comme l’école élémentaire ou le joueur, sont déclarés en tant qu’attributs, alors que les éléments dynamiques, comme les objets Kenny, sont stockés dans une liste.

Enrichissement du service

Maintenant que la structure de données est définie, nous allons enrichir la classe StatePersistenceService afin qu’elle puisse traiter les données spécifiques à notre scène. L’objectif est donc de créer l’état de plus haut niveau GameData, et définir l’interface permettant aux classes héritant de StateComponent d’enregistrer leur état auprès du service.

 // /Assets/Src/Persistence/StatePersistenceService.cs
 // ...
 using UnityEngine;
 
+using Lavoiedudev.Game.Persistence.Data;
+
 
 namespace Lavoiedudev.Game.Persistence
 {
 // ...
         private bool _stateLoaded;
 
+        /// <summary>
+        /// La référence au dernier objet d'état de scène créé.
+        /// </summary>
+        private GameData GameData;
+
         public void Awake()
 // ...
         public void Persist()
         {
+            // Création de l'état de la scène vide
+            GameData = new GameData();
+
             foreach (StateComponent component in _components)
             {
 // ...
             // TODO: Sauvegarde dans un fichier
         }
+
+        // PUBLIC API
+
+        /// <summary>
+        /// Sauvegarde les données relatives joueur.
+        /// </summary>
+        /// <param name="player">Les données du joueur</param>
+        public void SavePlayer(PlayerData player)
+        {
+            GameData.Player = player;
+        }
+
+        /// <summary>
+        /// Sauvegarde les données relatives à l'école élémentaire.
+        /// </summary>
+        /// <param name="school">Les données relatives à l'école élémentaire</param>
+        public void SaveSchool(SchoolData school)
+        {
+            GameData.School = school;
+        }
+
+        /// <summary>
+        /// Sauvegarde les données relatives à un Kenny sur la scène.
+        /// </summary>
+        /// <param name="kenny">Les données du Kenny</param>
+        public void SaveKenny(KennyData kenny)
+        {
+            if (!GameData.Kennys.Contains(kenny))
+            {
+                GameData.Kennys.Add(kenny);
+            }
+        }
     }
 }

L’évolution de la méthode Persist impose la création d’un état vierge à chaque appel. Cette stratégie garantit une structure fidèle à l’état courant. Les classes héritant de StateComponent, automatiquement inscrites auprès du service, seront notifiées pour enregistrer leur état via la méthode Snapshot.

En utilisant l’API publique Save…​() de la classe StatePersistenceService, chaque sous-classe de StateComponent peut recourir à l’une de ces méthodes pour implémenter la méthode Snapshot et sauvegarder son propre état.

Définition des classes filles

Continuons avec la définition des classes filles afin qu’elles puissent sauvegarder leur état.

La classe PlayerComponent

// /Assets/Src/Persistence/Components/PlayerComponent.cs
using UnityEngine;

using Lavoiedudev.Game.Persistence.Data;


namespace Lavoiedudev.Game.Persistence.Components
{
    public class PlayerComponent : StateComponent
    {
        [SerializeField] <1>
        private Player Player;

        protected override void Restore(StatePersistenceService service)
        {
            // TODO
        }

        public override void Snapshot(StatePersistenceService service)
        {
            // Création de l'état
            PlayerData data = new PlayerData(Player.Life);

            // Enregistrement de l'état
            service.SavePlayer(data);
        }
    }
}
1

Ce champ permet à Unity de référencer le composant Player correspondant à l’entité gérant le joueur (input, vie, etc.).

La classe SchoolComponent

// /Assets/Src/Persistence/Components/SchoolComponent.cs
using UnityEngine;

using Lavoiedudev.Game.Persistence.Data;


namespace Lavoiedudev.Game.Persistence.Components
{
    public class SchoolComponent : StateComponent
    {
        [SerializeField] <1>
        private School School;

        protected override void Restore(StatePersistenceService service)
        {
            // TODO
        }

        public override void Snapshot(StatePersistenceService service)
        {
            // Création de l'état
            SchoolData data = new SchoolData(School.Life);

            // Enregistrement de l'état
            service.SaveSchool(data);
        }
    }
}
1

Comme pour la classe PlayerComponent, l’état est construit à partir de l’entité gérant l’école élémentaire (bouclier, vie, etc.).

La classe KennyComponent

// /Assets/Src/Persistence/Components/KennyComponent.cs
using UnityEngine;

using Lavoiedudev.Game.Persistence.Data;


namespace Lavoiedudev.Game.Persistence.Components
{
    public class KennyComponent : StateComponent
    {
        [SerializeField] <1>
        private Kenny Kenny;

        protected override void Restore(StatePersistenceService service)
        {
            // TODO
        }

        public override void Snapshot(StatePersistenceService service)
        {
            // Création de l'état
            KennyData data = new KennyData(Kenny.State, Kenny.transform.position);

            // Enregistrement de l'état
            service.SaveKenny(data);
        }
    }
}
1

Encore une fois, l’état est construit à partir de l’entité gérant le GameObject dans la scène.

Intégration de la solution dans la scène Unity

L’ensemble des classes de la solution est maintenant implémenté. Nous pouvons passer à l’intégration de ces dernières via l’éditeur Unity.

Dans un premier temps, nous allons ajouter un GameObject vide à la racine de la scène (je l’ai nommé « Service »). Nous lui attachons ensuite la classe StatePersistenceService en cliquant sur le bouton « Add Component ». De cette manière, le service de persistance est présent dans la scène dès son lancement. Le service peut ainsi configurer l’environnement de sauvegarde automatiquement via la méthode Awake.

Dans un second temps, les différentes classes héritant de StateComponent doivent être ajoutées aux entités de la scène ou aux préfabs correspondants. N’oublions pas de les configurer correctement dans l’éditeur en référençant les instances correspondant à chaque champ annoté avec l’attribut [SerializeField], sinon en cas d’appel à la méthode Snapshot, une exception NullReferenceException sera levée.

Enfin, la dernière étape consiste à appeler la méthode Persist lorsque l’événement de sauvegarde survient. Dans le cas de mon mini jeu, j’ai ajouté un Button à l’IHM permettant de revenir au menu en sauvegardant la progression. Cela se traduit par l’ajout des instructions suivantes :

 // /Assets/Src/Dialog.cs
+        [SerializeField]
+        private StatePersistenceService Service; <1>
 // ...
+        private void OnSaveAndExit()
+        {
+            // Sauvegarde de l'état de la scène
+            Service.Persist(); <2>
+
+            // Retour au menu du jeu
+            // ...
+        }
 // ...
         public void Awake()
             // ...
+            SaveAndExitButton.clicked += OnSaveAndExit;
         }
 // ...
1

Le service est configuré dans la scène via l’éditeur Unity. Lorsque c’est possible, il est préférable d’utiliser l’instance réelle plutôt que le singleton.

2

Cette instruction permet d’exécuter la sauvegarde de l’état en appelant chaque composant éligible à la sauvegarde.

Sauvegarde dans un fichier

La dernière étape que nous allons voir est la sauvegarde des données dans un fichier. Jusqu’ici nous avons réussi à créer un état invariable de la scène, qui peut être généré à chaque appel à la méthode Persist du service. La sauvegarde des données se réalise en deux étapes :

  1. La sérialisation : cette étape consiste à encoder les données dans un format texte ou binaire que nous pourrons décoder.
  2. L’écriture sur disque dur : cette étape consiste à interagir avec le système d’exploitation pour écrire les données sérialisées dans un fichier sur le disque dur.

La sérialisation

Comme je l’ai mentionné précédemment, l’objectif est d’utiliser un format de données simple à manipuler. Nous allons donc utiliser l’utilitaire JsonUtility de l’API Unity pour encoder l’instance de la classe GameData au format JSON. L’utilisation des attributs [Serializable] et [SerializeField] dans les classes de données nous aide en ce sens.

string text = JsonUtility.ToJson(GameData);

Vous pouvez admirer la simplicité du code source. En une seule instruction, les données sont encodées sous la forme d’une string et ne contient que des caractères imprimables (non binaire).

L’écriture sur disque dur

Pour terminer la sauvegarde des données, nous devons développer la logique permettant d’écrire sur le disque dur. L’API Unity et les fonctions système C# vont nous y aider :

 // /Assets/Src/Persistence/StatePersistenceService.cs
 // ...
-            // TODO: Sauvegarde dans un fichier
+            // Sauvegarde dans un fichier
             string text = JsonUtility.ToJson(GameData);
+
+            //  Création du répertoire de destination
+            if (!Directory.Exists(Application.persistentDataPath))
+            {
+                Directory.CreateDirectory(Application.persistentDataPath);
+            }
+            // Tenter d'écrire dans le fichier "game.json" les données encodées en JSON
+            using (StreamWriter writer = File.CreateText(
+                Path.Join(Application.persistentDataPath, "game.json")
+            ))
+            {
+                writer.Write(text);
+            }
         }
 // ...

La logique ajoutée au code source permet au jeu de créer un répertoire de stockage propre à l’application (s’il n’existe pas déjà). Il écrase ensuite le contenu du fichier game.json avec les données précédemment sérialisées au format JSON. De cette manière, nous pouvons observer l’état de la scène dans le fichier.

Conclusion

En raison de sa complexité, la sauvegarde d’une scène Unity représente un défi technique majeur. Pour surmonter cette difficulté, un compromis technique a été adopté, permettant de gérer les éléments statiques et dynamiques sous la forme d’un état invariable représentatif de la scène. Cet état, hiérarchisé et composé de données primitives, qui facilitera le retour à la scène initiale.

L’implémentation repose sur un service central de sauvegarde, complété par plusieurs composants gérant l’état des sous-entités. Cette implémentation tire parti du cycle de vie du moteur Unity, ce qui permet une intégration harmonieuse de la solution. De plus, la sérialisation de l’état en JSON et son stockage sur disque à été grandement simplifié par l’utilisation des API C# et Unity disponibles.

Ainsi, notre solution s’appuyant essentiellement sur l’exploitation du moteur Unity et des API offertes, son implémentation est simple, fiable et s’intègre facilement à Unity pour sauvegarder une scène. Moyennant quelques adaptations, vous pourrez certainement l’intégrer avec la même facilité à votre propre jeu. Je vous donne donc rendez-vous dans mon prochain article, où nous finaliserons l’implémentation de la solution en ajoutant le code source responsable de la restauration de la scène.

Laisser un commentaire