Systèmes de sauvegarde pour un jeu sous Unity 3D

Lorsque votre jeu ou application atteindra une certaine complexité, il vous sera important de proposer un système de sauvegarde. À moins de faire un jeu qui ne se joue qu’en une seule session ininterrompue (ce qui ne correspond pas du tout aux usages d’aujourd’hui), il vous faut être en mesure de garder en mémoire la progression du joueur, voire une trace de ses précédentes partie ou records.

Dans cet article, nous allons étudier les moyens pour créer un système de sauvegardes, leurs limites et possibilités.

PlayerPrefs : le built-in prêt à l’emploi mais limité

Unity comporte un système prêt à l’emploi de sauvegarde rapide, PlayersPrefs. Cette classe a des avantages non négligeables : elle est relativement sécurisée, facile d’utilisation, et elle est disponible d’office sur tous les supports « de base » du player Unity (macOs, Windows, Linux, Android, WebGL…)

Cependant, la classe PlayerPrefs a quelques limites, car elle ne peut stocker que 3 types de données (entier, chaîne de caractères et float), et rend compliquées voire impossibles certaines manipulations sur les systèmes de sauvegarde. Par exemple, pas de sauvegarde dans le cloud avec Steam pour ce qui est dans PlayerPrefs.

La classe PlayerPrefs est à utiliser de préférence pour du prototypage rapide, sauvegarder la configuration de base du jeu, et les applications WebGL.

L’emplacement de la sauvegarde dépend directement du nom de la compagnie et du produit indiqués dans l’onglet « Player » du groupe d’options « Project Settings ». Voir la documentation officielle de PlayerPrefs pour connaître les emplacements de stockage.

Configuration du Player Unity où modifier « Company Name » et « Product Name »

Chaque élément sauvegardé avec PlayerPrefs est sauvegardé sous une clé (sous forme de chaîne de caractère) et avec une valeur (int, float ou string). Attention, soyez attentifs aux clés que vous créez, car vous pouvez vérifier si une clé existe, mais pas de quel type il s’agit.

Vous disposez des méthodes suivantes :

MéthodeUtilisationExemple
SetInt(string key, int value) Écrit un entier value dans la clé keySetInt("Lives", 3);
SetFloat(string key, float value)Écrit un float value dans la clé key SetFloat("Time", 1.05f);
SetString(string key, string value)Écrit une chaîne de caractères value dans la clé key SetString("Name", "Orion");
GetInt(string key)Récupère l’entier stocké dans la clé keyint playerLives = GetInt("Lives");
GetFloat(string key)Récupère le float stocké dans la clé key string completitionTime = GetFloat("Time")
GetString(string key)Récupère la chaîne stockée dans la clé key string playerName = GetString("Name");
HasKey(string key)Booléen : retourne « True » si un enregistrement existe sous la clé key
if(HasKey("Name")) {
     Say("Hello, " + GetString("Name" + "!"); }
else {
     Show(EnterName); 
}
Save()Enregistre toutes les préférences dans la mémoire de l’ordinateur*
DeleteKey(string key)Efface la clé qui porte la valeur keyDeleteKey("Lives"); // Efface le nombre de vies stocké en mémoire
DeleteAll()Efface toutes les clés sauvegardées pour l’application

*La fonction Save() est appelée automatiquement lorsque l’application est fermée manuellement (ou sur Windows Phone / Store Apps, quand l’application est suspendue). Cependant, il est possible de l’appeler soi-même au cours de l’exécution de l’application, notamment pour éviter des pertes de données en cas de plantage de l’application.

Les fonctions GetXxx() acceptent un deuxième argument, defaultValue. C’est la valeur qui est présentée si la clé key n’existe pas.

Les fonctions SetXxx() créent la clé si elle n’existe pas, ou mettent à jour sa valeur si elle existe déjà.

Le principe de la Serialization et la représentation de l’état de l’application

Avant de comprendre comment sauvegarder, il faut comprendre le quoi : quelles sont les données que nous allons chercher à sauvegarder.

La serialization consiste à convertir un objet de votre application en suite d’informations plus petites pour permettre son stockage, sa sauvegarde.

En l’occurrence, c’est ce que nous allons chercher à faire : notre objet va être l’état actuel du jeu (par exemple : niveau actuel du joueur, sa position dans le niveau, les objets ramassés…), et c’est cet objet que nous allons chercher à convertir en fichier.

Dans le cas d’Unity, il existe aussi un attribut qui permet de présenter directement un champ dans l’interface de l’éditeur : [SerializeField]. C’est bien le même terme, sauf que c’est juste un champ et non pas tout un objet qui est serialized.

Voici à quoi peut ressembler une classe qui représente l’état du jeu. Elle contient le nom saisi par le joueur, un entier représentant le niveau actuel du jeu, un Vector3 représentant la position du joueur, et une liste d’objets de type « Item », correspondant aux objets ramassés par le joueur.

[System.Serializable]
public class GameState
{
    public string PlayerName;
    public int CurrentChapter;
    public Vector3 PlayerPosition;
    public List<Item> CollectedObjects;

    public GameState(string playerName)
    {
        PlayerName = playerName;
        CurrentChapter = 1;
        PlayerPosition = Vector3.zero;
        CollectedObjects = new List<Item>();
    }
}

À noter :

  1. La classe ne dérive pas de MonoBehaviour, car elle ne sera pas ajoutée à un GameObject. Elle sera utilisée par les scripts en-dehors de MonoBehavior.
  2. L’attribut [System.Serializable] précédant la classe indique qu’elle peut être serialized.
  3. Je propose un constructeur qui ne prend que le nom du joueur et indique des valeurs par défaut dans les autres variables (typiquement, on instancie ce GameState après avoir récupéré le nom du joueur).
  4. Toutes les variables sont publiques. Ce n’est pas une obligation, la classe peut très bien n’être modifiable que par des méthodes, par exemple. Mais pour que les champs puissent être serialized, il faut qu’ils soient public ou qu’ils aient un attribut [SerializeField].

Si sur mon GameObject « GameManager », j’ai une instance CurrentState de la classe GameState présentée ci-dessus, elle sera présentée ainsi dans l’inspecteur :

Exemple de présentation d’une variable de classe GameState vue ci-dessus.

Nous avons donc une représentation de l’état actuel du jeu, que nous pouvons mettre à jour de manière régulière. Il ne nous reste plus qu’à trouver une méthode pour le sauvegarder.

BinaryFormatter : une classe à éviter !

Là aussi, quand j’ai voulu créer mon système de sauvegarde sur mon propre projet, j’ai vu beaucoup de tutoriaux proposer BinaryFormatter, une classe C# .Net de sérialisation binaire. Elle a l’avantage de parfaitement fonctionner avec les objets créés dans Unity, et de créer des fichiers relativement obscurs à décoder. Elle semble donc tout avoir de la candidate idéale pour créer un système de sauvegarde.

Mais c’est aussi une classe qui n’est pas sécurisée et ne peut pas l’être. Ce n’est pas moi qui l’affirme, mais la documentation officielle de Microsoft. Je vous invite à lire le guide de sécurité BinaryFormatter qui explique en détail pourquoi on ne peut jamais être sûr qu’un fichier est digne de confiance, et un utilisateur peut trafiquer un fichier pour modifier le comportement d’une appli, ou même pour infecter votre ordinateur.

BinaryFormatter, en raison de ses risques de sécurité, est à éviter dans tout nouveau projet.

La bonne pratique : sauvegarder sous forme de fichier XML ou JSON

L’alternative au BinaryFormatter, c’est d’utiliser une classe qui sauvegarde le fichier en XML ou JSON.

XML et JSON sont des formats de structurations de données (sous format clé/valeurs), qui viennent du web mais dont l’usage va bien au-dela. Le XML structure les données avec des balises (comme un fichier HTML), tandis que le JSON structure les données avec des caractères particuliers { « key »: « value » }.

Ci-dessous, voici à quoi ressemblent des fichiers pour ces deux formats:

<GameState xmlns="http://schemas.datacontract.org/2004/07/" xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
	<CollectedObjects/>
	<CurrentChapter>5</CurrentChapter>
	<PlayerName>Hero</PlayerName>
	<PlayerPosition xmlns:a="http://schemas.datacontract.org/2004/07/UnityEngine">
		<a:x>12</a:x>
		<a:y>36</a:y>
		<a:z>9</a:z>
	</PlayerPosition>
</GameState>

{
  "GameState": {
    "CollectedObjects": {
    },
    "CurrentChapter": "5",
    "PlayerName": "Orion",
    "PlayerPosition": {
      "x": "12",
      "y": "36",
      "z": "9"
    }
  },
}

Nous allons utiliser la classe DataContractSerializer. Pour cela, il y a deux espaces de nom à ajouter sur votre fichier :

using System.IO;
using System.Runtime.Serialization;

IO gère les FileStream, ce qui permet d’ouvrir et écrire les fichiers.

Runtime.Serialization contient la classe DataContractSerializer dont nous allons avoir besoin pour convertir des objets en XML.

Méthode d’écriture de fichier XML

Voici à quoi ressemble une fonction pour écrire des données (version rudimentaire) :

public static class SaveManager
{
    public static void Write(GameState state)
    {
        DataContractSerializer serializer = new DataContractSerializer(typeof(GameState));
        string filePath = Application.persistentDataPath + "/save.sav";
        FileStream file = File.Create(filePath);
        if (file != null)
        {
            serializer.WriteObject(file, state);
            file.Close();
#if UNITY_EDITOR
            Debug.Log("Save file written at "+filePath);
#endif
        }
    }
}

Une instance de DataContractSerializer est créée en fonction d’une classe existante. Ainsi, ci-dessous, nous créons l’instance serializer capable de lire et d’écrire des objets de classe GameState.

Nous utilisons pour le chemin du fichier la valeur Application.persistentDataPath, qui va automatiquement pointer vers un répertoire de la machine (voir les chemins sur la documentation Unity). Le fileStream « File » ouvre donc un fichier save.sav dans le répertoire dédié.

Par défaut, sous Windows 10, c’est un répertoire qui se trouve dans :
C:\Users\[nom d'utilisateur]\AppData\LocalLow\[Nom de compagnie]\[Nom de l'application]\save.sav

La fonction WriteObject() de notre serializer va écrire dans le fichier l’objet « state » passé en argument de la fonction.

Enfin, nous fermons notre FileStream pour libérer les ressources associées.

Méthode de lecture de fichier XML

Pour lire le fichier sauvegardé, c’est la même chose dans l’autre sens : on ouvre un FileStream, on utilise un serializer pour lire l’objet qui s’y trouve.

Étant donné que nous utilisons le même Serializer, j’en ai fait une variable statique utilisable par les deux fonctions.

Pour l’exemple, j’ai fait de même pour FilePath, même si dans une utilisation réelle, on peut faire plusieurs fichiers de sauvegarde, sans avoir forcément de partie fixe.

public static class SaveManager
{
    public static DataContractSerializer Serializer { get; private set; } = new DataContractSerializer(typeof(GameState));
    public static string FilePath { get; private set; } = Application.persistentDataPath + "/save.sav";

    public static bool TryRead(out GameState state)
    {
        if(File.Exists(FilePath))
        {
            FileStream file = File.Open(FilePath, FileMode.Open);
            state = (GameState)Serializer.ReadObject(file);
            file.Close();
            return true;
        }
        state = null;
        return false;
    }
    public static void Write(GameState state) { // ...
    }
}

Au lieu de faire une fonction qui retourne directement un objet GameState, ici je propose une fonction qui retourne un booléen, ça sera false si le fichier de sauvegarde n’existe pas. Ainsi, la même fonction teste la sauvegarde, et la récupère dans la variable out state si le fichier existe. Ce qui permet de prévoir la situation où la sauvegarde n’existe pas.

Concrètement, cette fonction TryRead() va s’utiliser de cette manière :

using UnityEngine;

public class GameManager : MonoBehaviour
{
    public GameState CurrentState;

    void Start()
    {
        if (SaveManager.TryRead(out GameState state))
        {
            CurrentState = state;
        }
        else
        {
            // Système de saisie de nom
            CurrentState = new GameState("My name");
            SaveManager.Write(CurrentState);
        }
    }
}

À partir de cette base, vous pouvez faire votre propre système de sauvegarde, aussi complet que possible pour qu’il colle à la façon dont votre jeu fonctionne.

Aller plus loin avec votre système de sauvegarde : réflexions et indications

Les sauvegardes en XML / JSON sont elles trop peu sécurisées ?

Un des freins que j’ai pu avoir en passant de BinaryFormatter à DataContractSerializer était que les fichiers ainsi obtenus semblaient trop évident à altérer. Puis, en parcourant des échanges sur le web et en y réfléchissant, j’en ai conclu ça n’était pas quelque chose de vraiment grave. Après tout, sur un jeu solo, ça n’ennuie personne si un utilisateur souhaite tricher. Il a toujours été possible de tricher d’une manière ou d’une autre sur les jeux locaux, du GameShark pour les consoles aux mods ou éditeurs de sauvegarde.

Dans notre cas, avant de modifier un fichier sauvegardé, encore faut-il que le joueur ait une idée d’où sont les sauvegardes (ce qui n’est pas si évident), puis qu’il ouvre le fichier et comprenne à quoi correspondent les différentes variables.

Pour un jeu solo, le fait que les sauvegardes soient modifiables par le joueur, ce n’est pas grave : il n’y a pas d’impact sur d’autres joueurs.

De même, un effet de bord d’avoir un fichier de sauvegarde au format XML ou JSON a un effet de bord positif : lors des tests de votre jeu, vous pourrez directement consulter les sauvegardes, et les modifier à la volée, par exemple pour débloquer la progression du jeu chez un joueur s’il est tombé sur un bug bloquant. Donc vous

Si vous avez un système de contenus payants, du on-line, des succès, etc., il vous faudra de toutes façons passer par d’autres systèmes de vérification pour savoir à quoi l’utilisateur a accès, car un fichier en local est modifiable par l’utilisateur, donc par définition, pas fiable à 100%.

Comment sécuriser une sauvegarde Unity au format JSON / XML ?

Bien sûr, pour sécuriser un peu les fichiers et éviter qu’ils soient simples à modifier, vous pouvez faire un système d’offuscation et de chiffrement (en modifiant les valeurs à l’enregistrement et en les décodant au chargement, en donnant des noms de variables dans le fichier qui ne rend pas le système simple à décoder…).

Vous pouvez aussi utiliser des variables de vérification de l’intégrité du fichier (de type checksum). Ainsi, si un joueur curieux modifie son score ou ses statistiques en allant modifier sa sauvegarde, le fichier sera considéré par invalide par le système.

Dans tous les cas, ne cédez pas à la tentation d’utiliser BinaryFormatter, qui n’est pas sécurisé !

Que sauvegarder ? Et comment sauvegarder ?

Sur les illustrations ci-dessus, on ne sauvegarde que des éléments de progression du joueur. Ce ne sont que quelques exemples par rapport à ce que permet un tel système de sauvegarde :

  1. Sauvegarder les paramètres choisis par le joueur (langue du jeu, volume de la musique et du son, options diverses…), soit par joueur, soit au niveau global pour le jeu
  2. Proposer plusieurs sauvegardes. Pour cela, vous pouvez modifier les méthodes de votre SaveManager pour qu’elles écrivent dans un fichier différent selon le contexte.
  3. Pensez vos sauvegardes comme des éléments qui peuvent évoluer si vous ajoutez des nouveautés au jeu. Ça passe par l’utilisation d’éléments modulaires dans les sauvegardes (par exemple une liste de variables), voire par un versionnage des sauvegardes. Il faut prévoir l’évolution de votre jeu assez en amont, car si vous modifiez votre classe GameState et que vous essayez d’ouvrir un ancien fichier, vous recevrez une SerializationException et le fichier ne sera pas chargé !

J’espère que cet article vous aura aidé à faire un système de sauvegarde sur mesure pour votre jeu !