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 seconde partie du chemin qui correspond à la restauration depuis le disque.

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

Afin d’aider mon frère à créer son jeu vidéo, dans mon précédent article, j’ai présenté une solution de sauvegarde de scène Unity en C#. Malgré une conception tardive durant le développement du jeu, cette solution s’intègre très facilement. Nous avons réussi à sauvegarder l’état de la scène dans un fichier au format JSON. L’objectif de cet article est de présenter comment récupérer le contenu de ce fichier et restaurer la scène à partir des informations sauvegardées, afin de reprendre la partie exactement là où nous l’avons laissée.

Si vous avez loupé l’article en question, et donc la conception de la solution, vous pouvez le consulter à partir du lien suivant :

Notifier la restauration de la scène

Mise à jour de la classe StatePersistenceService

Pour pouvoir reprendre une partie sauvegardée, il est nécessaire de contrôler l’existence du fichier contenant l’état de la scène. Nous allons mettre à jour notre service pour éviter de dupliquer le code source de calcul du chemin du fichier :

 // /Assets/Src/Persistence/StatePersistenceService.cs
 // ...
     /// </summary>
     public class StatePersistenceService : MonoBehaviour
     {
+        /// <summary>
+        /// Retourne le chemin vers le fichier de sauvegarde
+        /// </summary>
+        public static string BackupPath { get => Path.Join(Application.persistentDataPath, "game.json"); } <1>
+
         private static StatePersistenceService _Instance;
         /// <summary>
         /// Référence à l'instance unique de la scène.
 // ...
                 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")
-            ))
+            using (StreamWriter writer = File.CreateText(BackupPath))
             {
                 writer.Write(text);
             }
 // ...
1

Application.persistentDataPath n’étant accessible qu’à l’exécution du programme, afin d’y avoir accès globalement, nous devons utiliser un attribut de classe. J’utilise ici une propriété calculée par souci de simplification.

Mise à jour du Menu

Il est maintenant possible d’intégrer les instructions suivantes à la classe du menu principal. Elles permettent ainsi de notifier le service de persistance de restaurer la scène.

 // /Assets/Src/Menu/UIManager.cs
+using System.IO;
+
 using UnityEngine;
 using UnityEngine.SceneManagement;
 using UnityEngine.UIElements;

+using Lavoiedudev.Game.Persistence;
+

 namespace Lavoiedudev.Game.Menu
 {
 // ...

             PlayButton.clicked += OnPlayClicked;
             ContinueButton.clicked += OnContinueClicked;
+
+            if (!File.Exists(StatePersistenceService.BackupPath))
+            {
+                HideContinueButton(); <1>
+            }
         }

 // ...

         private void OnContinueClicked()
         {
+            // Notifier le service que la scène doit être restaurée
+            StatePersistenceService.Restore = true; <2>
+
             StartGame();
         }
     }
 }
1

Le bouton « CONTINUER » est visible par défaut, lorsque la sauvegarde n’existe pas, ce bouton doit être masqué.

2

Nous utilisons l’attribut global Restore du service pour le notifier de configurer la scène à partir du fichier de sauvegarde.

Charger les données de la scène depuis le fichier de sauvegarde

Lors de la sauvegarde, il est important d’utiliser un format de données facile à manipuler. Cela permet de convertir une structure de données en mémoire en une version sérialisée, de la stocker dans un fichier, et de pouvoir effectuer l’opération inverse tout aussi simplement. C’est pourquoi nous avons choisi le format JSON, que nous pouvons manipuler avec l’utilitaire JsonUtility.
La méthode LoadBackup, que nous avons laissé vide jusqu’à présent, peut être implémentée de la manière suivante :

 // /Assets/Src/Persistence/StateServicePersistence.cs
 // ...
         /// </summary>
         private void LoadBackup()
         {
-            // TODO: lecture du fichier
-
-            _stateLoaded = true;
+            string path = BackupPath;
+            if (File.Exists(path)) <1>
+            {
+                // Lecture du fichier possible
+                string content;
+                using (StreamReader reader = File.OpenText(path))
+                {
+                    content = reader.ReadToEnd();
+                }
+                // Parsing du JSON pour récupérer l'état de la scène
+                GameData = JsonUtility.FromJson<GameData>(content);
+
+                // Lecture OK, nettoyage pour éviter de pouvoir reprendre la partie en boucle
+                Restore = false;
+                File.Delete(path);
+
+                _stateLoaded = true;
+            }
+            else
+            {
+                _stateLoaded = false;  // Aucune données récupérées
+            }
         }
 // ...
1

Il est important de refaire le test ici, car la méthode LoadBackup réalise la suppression du fichier après un chargement réussi.

Après cette modification, lorsque l’utilisateur clique sur le bouton « CONTINUER », au lancement de la scène, le service de persistance peut charger automatiquement les données depuis le fichier. Les instances fille de la classe StateComponent peuvent ensuite appeler la méthode Restore(StatePersistenceService service) pour reconfigurer chaque entité de la scène à partir de leur état sauvegardé.

Restaurer les entités statiques

Pour les entités statiques et dynamiques, la méthode de restauration présente quelques différences. Les entités statiques étant présentes dès la création de la scène, la stratégie de restauration est simplifiée par rapport à celle des entités dynamiques. Nous allons commencer par implémenter la méthode Restore des classes PlayerComponent et SchoolComponent.

Ajouter les getters d’état

Les composants responsables de la sauvegarde et de la restauration doivent avoir accès à l’état chargé par le service. Pour le moment, le service n’offre que l’API de sauvegarde d’état. Nous devons donc ajouter les méthodes permettant de le récupérer.

 // /Assets/Src/Persistence/StatePersistenceService.cs
 // ...
             GameData.Player = player;
         }

+        /// <summary>
+        /// Retourne l'état du joueur chargé.
+        /// </summary>
+        /// <returns>L'état du joueur ou null</returns>
+        public PlayerData LoadPlayer()
+        {
+            return GameData?.Player ?? null;
+        }
+
         /// <summary>
         /// Sauvegarde les données relatives à l'école élémentaire.
 // ...
             GameData.School = school;
         }

+        /// <summary>
+        /// Retourne l'état de l'école chargé.
+        /// </summary>
+        /// <returns>L'état de l'école ou null</returns>
+        public SchoolData LoadSchool()
+        {
+            return GameData?.School ?? null;
+        }
+
         /// <summary>
         /// Sauvegarde les données relatives à un Kenny sur la scène.
 // ...

A cette étape, seules les méthodes liées aux données PlayerData et SchoolData sont ajoutées.

Définir la logique de restauration

Il est maintenant possible de définir les instructions de restauration de nos entités statiques :

 // /Assets/Src/Persistence/Components/PlayerComponent.cs
 // ...
         protected override void Restore(StatePersistenceService service)
         {
-            // TODO
+            Player.InitialState = service.LoadPlayer();
         }
 // ...

Comme vous pouvez le voir, l’implémentation est très simple. Nous affectons la donnée chargée à la propriété InitialState de chaque classe gérant l’entité durant la partie.

Nous poursuivons ensuite avec l’ajout de la logique de restauration dans chaque entité. L’exemple suivant présente celle qui est propre à la classe Player de mon mini-jeu. Vous pouvez vous en inspirer pour vos propres entités.

 // /Assets/Src/Player.cs
 using System;
+
 using UnityEngine;
+ 
+using Lavoiedudev.Game.Persistence.Data;


 public class Player : MonoBehaviour
 {
 // ... 
+    /// <summary>
+    /// Etat initial à utiliser pour restaurer le joueur.
+    /// </summary>
+    public PlayerData InitialState { get; set; }
+
+    public void Awake()
+    {
+        InitialState = null;
+    }
+
     public void Start()
     {
+        if (InitialState != null)
+        {
+            _life = InitialState.Life;
+        }
+
         // ...
     }
 // ...

La logique est similaire pour la classe School. Les modifications ne sont pas présentées ici, mais vous pouvez les consulter sur GitLab.

Un problème d’ordonnancement

L’origine du dysfonctionnement

Si vous testez ce code source, il est probable que certaines entités ne se restaurent pas.

Malheureusement, le moteur Unity ne garanti pas l’ordre d’exécution des différentes méthodes du cycle de vie des objets. Cela signifie que l’ordre des composants sur un GameObject, ou l’ordre des GameObject dans la hiérarchie de la scène n’influencent pas nécessairement l’ordre d’exécution des méthodes. Cet aspect du moteur de jeu explique donc ce comportement.

Lors du lancement de la scène, nous sommes confrontés à deux situations :

  1. La méthode Start() de l’entité s’exécute avant celle du composant responsable de la restauration.
  2. La méthode Start() du composant responsable de la restauration s’exécute avant celle de l’entité.

Correction de la classe StateComponent

Nous devons donc faire évoluer le code source pour s’assurer d’exécuter l’initialisation de l’entité après l’exécution du Start() des composants de restauration. Je vous propose donc l’amélioration suivantes :

 // /Assets/Src/Persistence/StateComponent.cs
+using System;
+using System.Collections.Generic;
+
 using UnityEngine;
 
 // ...
     public abstract class StateComponent : MonoBehaviour
     {
+        /// <summary>
+        /// Flag permettant de savoir si la méthode Start() à été appelée.
+        /// </summary>
+        private bool _ready = false;
+
+        /// <summary>
+        /// Liste des callbacks à appeler après le Start().
+        /// </summary>
+        private readonly List<Action> _onStartedCallbacks = new List<Action>();
+
+        /// <summary>
+        /// Enregistre un callback à appeler lorsque le composant est prêt.
+        /// </summary>
+        /// <param name="callback">Le callback à exécuter.</param>
+        public void DoWhenStarted(Action callback)
+        {
+            if (_ready) // Le start à déjà été appelé, on exécute immédiatement
+            {
+                callback();
+            }
+            else // Stockage pour une exécution différée
+            {
+                _onStartedCallbacks.Add(callback);
+            }
+        }

         public void Start()
         {
 // ...
                 Restore(StatePersistenceService.Instance);
             }
+
+            // Le composant est lancé, exécution des callbacks en attente
+            for (int count = _onStartedCallbacks.Count; count > 0; count--)
+            {
+                // Exécution du premier callback, puis suppression
+                _onStartedCallbacks[0]();
+                _onStartedCallbacks.RemoveAt(0);
+            }
+            _ready = true;
         }

Grâce à cette amélioration, nous pouvons enregistrer des callbacks à exécuter après l’appel à la méthode Start(). Le flag _ready assure l’exécution de ces callbacks, soit immédiatement, soit de manière différée, selon que la méthode Start() a été appelée ou non.

Adaptation de la classe Player

Ainsi, nous pouvons adapter la classe Player afin de garantir que les instructions d’initialisation s’exécutent après le traitement de restauration :

 // /Assets/Src/Player.cs
 public class Player
 {
+    /// <summary>
+    /// Référence au composant de sauvegarde/restauration d'état.
+    /// </summary>
+    [SerializeField]
+    private StateComponent StateComponent;
 // ...

     public void Start()
         {
-        if (InitialState != null)
-        {
-            _life = InitialState.Life;
-        }
+        StateComponent.DoWhenStarted(() => { <1>
+            if (InitialState != null)
+            {
+                _life = InitialState.Life;
+            }
+
+            // ... (autre actions dépendantes de InitialState)
+        });
     }
1

Utilisation d’une expression lambda pour éviter de déclarer une méthode dédiée à l’initialisation de l’entité Player.

Restaurer les entités dynamiques

Solution de restauration au lancement de la scène Unity

Contrairement aux entités statiques, qui sont présentes dès le lancement de la scène, les entités dynamiques doivent être instanciées explicitement en exécutant les instructions adéquates. Cette stratégie passe par la déclaration d’une classe d’initialisation qui peut :

  • Soit être déclarée globalement : cette unique classe assure l’instanciation et la configuration dans un ordre précis ;
  • Soit être déclarée spécifiquement : chaque composant de la scène responsable de l’instanciation des entités dynamiques s’occupe des entités dont il a la charge. Dans mon mini-jeu, la classe KennySpawner préparerait la restauration de chaque instance de Kenny.

L’initialisation de la scène via une unique instance est une solution générique pouvant s’adapter à n’importe quelle situation, je vous propose donc une solution en ce sens.

Modification de la classe StatePersistenceService

Commençons par modifier la classe StatePersistenceService pour récupérer la liste des Kenny :

 // /Assets/Src/Persistence/StatePersistenceService.cs
 // ...
+
+        /// <summary>
+        /// Retourne les états des Kenny chargés.
+        /// </summary>
+        /// <returns>Une liste non null des états Kenny</returns>
+        public List<KennyData> LoadKennys()
+        {
+            return GameData?.Kennys ?? new List<KennyData>();
+        }
    }
}

La classe InitializeComponent

Continuons avec l’implémentation de la classe InitializeComponent :

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

using Lavoiedudev.Game.Persistence.Data;


namespace Lavoiedudev.Game.Persistence.Components
{
    public class InitializeComponent : StateComponent
    {
        /// <summary>
        /// Référence à l'instance qui instancie les Kenny.
        /// </summary>
        [SerializeField]
        private KennySpawner KennySpawner;

        protected override void Restore(StatePersistenceService service)
        {
            foreach (KennyData data in service.LoadKennys())
            {
                KennySpawner.SpawnUsing(data); <1>
            }
        }

        public override void Snapshot(StatePersistenceService service)
        {
            // Ignore
        }
    }
}
1

Nous déléguons la création des Kenny au composant qui en est responsable.

Explications de l’implémentation

En héritant de la classe StateComponent, la classe fille InitializeComponent s’intègre facilement dans le processus de restauration. Nous n’avons qu’à implémenter la méthode Restore(StatePersistenceService service) pour que le système existant exécute automatiquement les traitements de restauration des entités dynamiques.

L’implémentation de la méthode SpawnUsing(KennyData data) repose sur l’utilisation de la méthode privée existante SpawnKenny(), qui a été modifiée pour placer l’entité Kenny à la position définie dans l’objet KennyData et la configurer dans son état d’arrêt. En réutilisant les instructions existantes, l’entité Kenny est instanciée selon le processus standard de spawn, ce qui permet d’éviter la duplication de code et les oublis potentiels lors de l’initialisation. La configuration finale de l’entité est ensuite réalisée lors de l’appel à la méthode Start(), comme nous l’avons vu pour la classe Player.

Utiliser des identifiants uniques

Des relations complexes dans la scène Unity

Parfois, les relations entre les différentes entités d’une scène sont plus complexes que celles de mon mini-jeu.

Par exemple, dans un jeu de stratégie, il peut être possible d’affecter des gardes à une tour de guet. Ces entités « garde » sont alors associées à des entités de type bâtiment. Lors de la sauvegarde, bien que l’information du garde assigné à la tour doive être conservée, il est préférable de ne pas inclure l’état du garde directement dans la structure de celui de la tour de guet. Cela permet d’éviter la duplication de données et de limiter les traitements nécessaires lors de la restauration, tout en réduisant les risques d’erreurs.

Représentation des relations par identifiants uniques

Qu’elle soit statique ou dynamique, lorsqu’une entité entretient des relations avec d’autres entités, une solution courante consiste à utiliser des identifiants uniques pour chacune d’elle. L’état complet de l’entité, accompagné de son identifiant, est alors stocké dans une structure appropriée. Ensuite, pour représenter la relation, l’autre entité ne conserve que l’identifiant.

Dans l’exemple précédent, cette approche consiste à stocker l’état des gardes dans une liste (comme nous l’avons fait pour les KennyData), tandis que l’état de la tour de guet ne référencera que l’identifiant du ou des gardes qui y sont affectés.

Uniformisation de la restauration

L’identifiant unique peut également être utilisé pour uniformiser les comportements de restauration. Toutefois, pour mon mini-jeu, cette stratégie serait inappropriée, car elle alourdirait le code sans apporter de réels bénéfices. Néanmoins, à des fins pédagogiques, je vais vous décrire la démarche et les modifications nécessaires pour adopter cette approche :

  1. La classe Kenny doit déclarer un nouveau champ identifiant, qui sera généré et affecté lors de l’appel à sa méthode Start().
  2. Les classes KennyData et KennyComponent doivent être modifiées pour sauvegarder ce nouvel identifiant.
  3. La classe StatePersistenceService doit déclarer une méthode supplémentaire LoadKennyById(int id) permettant de retrouver les données d’un Kenny via son identifiant dans la liste.
  4. La classe InitializeComponent fournirait l’identifiant plutôt que l’instance KennyData à la méthode SpawnUsing(...).
  5. La classe KennyComponent appellerait la méthode LoadKennyById(int id) lors de l’exécution de la méthode Restore, ce qui permet d’affecter l’instance KennyData à l’entité Kenny.
  6. Enfin, la classe Kenny appliquerait la même logique que les classes Player et School pour se reconfigurer à partir de l’état, lors de l’exécution de l’expression lambda fournie en paramètre à la méthode DoWhenStarted(...).

Restaurer les relations entre entités

Si vous souhaitez restaurer les relations entre les entités, la référence au GameObject ou au composant spécifique instancié doit être conservée par la classe InitializeComponent (ou son équivalent), puis récupérée par l’autre entité lors de sa restauration en utilisant l’identifiant précédemment sauvegardé.

Dans l’exemple des gardes et des tours de guet, en complément de la logique décrite ci-dessus, la classe InitializeComponent stockerait la référence à l’instance du garde recréé. Ensuite, lors de l’exécution de l’expression lambda définie par la classe Watchtower (entité « tour de guet »), cette dernière pourrait récupérer l’instance du garde à partir de l’identifiant sauvegardé dans l’état restauré.

Démo jouable dans le navigateur

Vous pouvez tester directement le résultat de cette solution ci-dessous.
Appuyez sur « Échap » pour accéder au menu de pause, sauvegarder votre progression, puis reprendre exactement là où vous vous étiez arrêté.

Cette fonctionnalité fonctionne aussi bien sur PC que dans un build WebGL, ce qui confirme la compatibilité multiplateforme du système.

Conclusion

Nous avons réussi à concevoir et implémenter une solution complète de sauvegarde et de restauration d’une scène Unity en C#, permettant de reprendre une partie exactement là où elle a été interrompue. À partir d’une structure de sauvegarde en JSON, nous avons développé une méthode robuste capable de recharger la scène, en restaurant les entités statiques et dynamiques, tout en prenant en compte la complexité liée à l’ordonnancement des appels des méthodes du cycle de vie de Unity.

Cette solution, bien qu’adaptée à mon mini-jeu, présente une grande flexibilité et peut être intégrée facilement à d’autres projets. D’ailleurs, grâce à l’utilisation d’identifiants uniques et de techniques de restauration appropriées, il est possible de gérer des cas complexes de relations entre entités.

L’implémentation est maintenant opérationnelle et reste évolutive pour des projets plus ambitieux. Vous pouvez vous en inspirer et l’adapter à vos propres besoins, en gardant en tête les principes fondamentaux de cette approche.

Laisser un commentaire