Conception d'un Système de Gestion de Données via ScriptableObject

Dans l'architecture des projets Unity, la classe ScriptableObject est couramment exploitée pour structurer des bases de données statiques. Bien que son comportement natif la destine principalement à la lecture seule lors de l'exécution, il est tout à fait possible de lui adjoindre un mécanisme de persistance local. Cette approche permet de transformer ces structures en tables de données dynamiques, capables de sauvegarder et de charger des modifications en temps réel, tout en conservant un éditeur visuel performant.

Organisation du Module

Pour assurer la maintenabilité, le système est divisé en plusieurs espaces de noms et répertoires logiques :

  • Core : Contient les classes génériques et abstraites qui gèrent le cycle de vie des données.
  • Contracts : Définit les interfaces imposant le comportement standard des tables et des enregistrements.
  • Editor : Regroupe les outils d'extension pour l'inspecteur Unity, facilitant l'édition des assets.
  • Definitions : Héberge les implémentations concrètes spécifiques au jeu (ex: configurations d'interface, statistiques).

Fondations et Interfaces

Le système repose sur une abstraction stricte. Chaque table de données dérive d'une classe commune héritant de ScriptableObject.

using UnityEngine;
using System;

public abstract class AbstractDataSheet : ScriptableObject
{
    public abstract Type RecordType { get; }
}

Les contrats définissent les interactions autorisées sur la collection de données, assurant un accès en lecture seule par défaut et exposant des événements de modification.

using System;
using System.Collections.ObjectModel;

public interface IDataManager<TSheet, TRecord> 
    where TSheet : AbstractDataSheet 
    where TRecord : IDataRecord
{
    ReadOnlyCollection<TRecord> Records { get; }
    int Count { get; }
    event Action<TRecord> OnDataModified;
    void ResetToDefault();
    TRecord FindRecord(Predicate<TRecord> condition);
    void UpdateRecords(Func<TRecord, TRecord> modifier);
}

public interface IDataRecord { }

Contrôleur de Données Générique

La classe centrale orchestre la sérialisation JSON, la gestion des tableaux en mémoire et la communication avec le système de fichiers local. Contrairement aux approches classiques utilisant des flux complexes, cette implémentation s'appuie sur les méthodes synchrones natives de System.IO pour une meilleure lisibilité.

using UnityEngine;
using System;
using System.IO;
using System.Collections.ObjectModel;

public class DynamicDataTable<TSheet, TRecord> : AbstractDataSheet, IDataManager<TSheet, TRecord>
    where TSheet : DynamicDataTable<TSheet, TRecord>
    where TRecord : IDataRecord
{
    [SerializeField] private bool enableRuntimePersistence = true;
    [SerializeField, HideInInspector] private TRecord[] records;
    
    private string persistencePath;
    
    public sealed override Type RecordType => typeof(TSheet);
    public ReadOnlyCollection<TRecord> Records => Array.AsReadOnly(records ?? Array.Empty<TRecord>());
    public int Count => records?.Length ?? 0;
    
    public event Action<TRecord> OnDataModified;

    protected virtual void Awake()
    {
        persistencePath = Path.Combine(Application.persistentDataPath, $"Data/{RecordType.Name}.json");
    }

    protected virtual void OnEnable()
    {
        records ??= Array.Empty<TRecord>();
        if (enableRuntimePersistence && !Application.isEditor)
        {
            LoadLocalData();
        }
    }

    protected virtual void OnDisable()
    {
        if (enableRuntimePersistence && !Application.isEditor)
        {
            SaveLocalData();
        }
    }

    public virtual void SaveLocalData()
    {
        if (Application.isEditor) return;

        string directory = Path.GetDirectoryName(persistencePath);
        if (!Directory.Exists(directory)) Directory.CreateDirectory(directory);

        string json = JsonUtility.ToJson(this, true);
        File.WriteAllText(persistencePath, json);
    }

    public virtual void LoadLocalData()
    {
        if (Application.isEditor || !File.Exists(persistencePath)) return;

        try
        {
            string json = File.ReadAllText(persistencePath);
            JsonUtility.FromJsonOverwrite(json, this);
            NotifyDataChanged();
        }
        catch (Exception ex)
        {
            Debug.LogError($"[DataManager] Échec du chargement : {ex.Message}");
        }
    }

    public virtual void ResetToDefault()
    {
        var defaultSheet = Resources.Load<TSheet>($"DataSheets/{RecordType.Name}");
        if (defaultSheet != null && defaultSheet.records != null)
        {
            records = new TRecord[defaultSheet.records.Length];
            Array.Copy(defaultSheet.records, records, records.Length);
            NotifyDataChanged();
        }
    }

    public virtual TRecord FindRecord(Predicate<TRecord> condition)
    {
        if (records == null || condition == null) return default;
        return Array.Find(records, condition);
    }

    public virtual void UpdateRecords(Func<TRecord, TRecord> modifier)
    {
        if (records == null || modifier == null) return;
        for (int i = 0; i < records.Length; i++)
        {
            records[i] = modifier(records[i]);
        }
        NotifyDataChanged();
    }

    private void NotifyDataChanged()
    {
        if (OnDataModified != null && records != null)
        {
            foreach (var record in records)
            {
                OnDataModified.Invoke(record);
            }
        }
    }
}

Infrastructure de l'Éditeur

Pour faciliter la création d'interfaces personnalisées dans l'inspecteur Unity, une classe de base encapsule la logique de sauvegarde des assets.

using UnityEditor;
using UnityEngine;

public class BaseSheetInspector : Editor
{
    protected SerializedProperty recordsProp;
    protected SerializedProperty autoSaveProp;

    protected virtual void OnEnable()
    {
        recordsProp = serializedObject.FindProperty("records");
        autoSaveProp = serializedObject.FindProperty("enableRuntimePersistence");
    }

    protected void DrawPersistenceControls()
    {
        EditorGUILayout.PropertyField(autoSaveProp, new GUIContent("Persistance à l'Exécution"));
        if (GUILayout.Button("Forcer la Sauvegarde de l'Asset"))
        {
            CommitChanges();
        }
    }

    protected virtual void CommitChanges()
    {
        serializedObject.ApplyModifiedProperties();
        EditorUtility.SetDirty(target);
        AssetDatabase.SaveAssets();
    }
}

Implémentation Pratique : Configuration du Curseur

Pour illustrer l'utilisation du système, voici la définition d'une table gérant les paramètres d'apparence du curseur. Les structures de données sont découplées de leurs composants d'interface utilisateur.

using System;
using UnityEngine;
using UnityEngine.UI;

[Serializable]
public class CursorUIBinding
{
    public string configurationKey;
    public Dropdown selectionDropdown;
    public Image previewIcon;
}

[Serializable]
public struct CursorConfigRecord : IDataRecord
{
    public string parameterName;
    public string parameterValue;

    public CursorConfigRecord(string name, string value)
    {
        parameterName = name;
        parameterValue = value;
    }
}

La table elle-même intègre des paramètres par défaut fortement typés. L'utilisation de l'attribut CreateAssetMenu permet la génération native de l'asset.

using UnityEngine;

[CreateAssetMenu(fileName = "NewCursorConfig", menuName = "Game Data/Cursor Configuration", order = 100)]
public sealed class CursorConfigSheet : DynamicDataTable<CursorConfigSheet, CursorConfigRecord>
{
    [SerializeField] private CursorType defaultType;
    [SerializeField] private Color defaultColor;
    [SerializeField] private float defaultScale;

    public CursorType DefaultType => defaultType;
    public Color DefaultColor => defaultColor;
    public float DefaultScale => defaultScale;
}

Personnalisation de l'Inspecteur

L'éditeur dédié exploite la classe ReorderableList pour offrir une manipulation fluide des enregistrements. L'approche ci-dessous délaisse la gestion manuelle complexe des index et chaînes de caractères au profit des PropertyDrawers natifs de Unity, garantissant un rendu fidèle des énumérations et des couleurs.

using UnityEditor;
using UnityEditorInternal;
using UnityEngine;

[CustomEditor(typeof(CursorConfigSheet))]
public class CursorConfigInspector : BaseSheetInspector
{
    private ReorderableList configList;
    private SerializedProperty typeProp, colorProp, scaleProp;

    private void OnEnable()
    {
        base.OnEnable();
        typeProp = serializedObject.FindProperty("defaultType");
        colorProp = serializedObject.FindProperty("defaultColor");
        scaleProp = serializedObject.FindProperty("defaultScale");

        configList = new ReorderableList(serializedObject, recordsProp, true, true, true, true)
        {
            drawHeaderCallback = (Rect rect) => EditorGUI.LabelField(rect, "Paramètres du Curseur"),
            drawElementCallback = DrawElement
        };
    }

    private void DrawElement(Rect rect, int index, bool isActive, bool isFocused)
    {
        var element = recordsProp.GetArrayElementAtIndex(index);
        rect.y += 2;
        float halfWidth = rect.width / 2 - 5;
        
        EditorGUI.PropertyField(new Rect(rect.x, rect.y, halfWidth, EditorGUIUtility.singleLineHeight), 
            element.FindPropertyRelative("parameterName"), GUIContent.none);
            
        EditorGUI.PropertyField(new Rect(rect.x + halfWidth + 10, rect.y, halfWidth, EditorGUIUtility.singleLineHeight), 
            element.FindPropertyRelative("parameterValue"), GUIContent.none);
    }

    public override void OnInspectorGUI()
    {
        serializedObject.Update();

        EditorGUILayout.Space();
        configList.DoLayoutList();

        EditorGUILayout.Space();
        EditorGUILayout.LabelField("Valeurs par Défaut", EditorStyles.boldLabel);
        EditorGUILayout.PropertyField(typeProp, new GUIContent("Type de Curseur"));
        EditorGUILayout.PropertyField(colorProp, new GUIContent("Couleur Principale"));
        EditorGUILayout.PropertyField(scaleProp, new GUIContent("Facteur d'Échelle"));

        EditorGUILayout.Space();
        DrawPersistenceControls();

        if (GUI.changed)
        {
            CommitChanges();
        }
    }
}

Analyse de l'Architecture

La classe AbstractDataSheet sert de point d'ancrage pour l'ensemble du module, assurant une intégration parfaite avec le système de sérilaisation de Unity. Le cœur de la logique réside dans DynamicDataTable, qui abstrait totalement les opérations de fichiers. En centralisant la sérialisation JSON et le déclenchement des événements de modification, elle garantit la cohérence des données entre la session d'édition et l'exécution sur le périphérique final.

L'interface IDataManager restreint l'accès direct au tableau sous-jacent via une collection en lecture seule (ReadOnlyCollection), forçant les systèmes externes à utiliser les méthodes dédiées comme UpdateRecords pour muter l'état. Cette encapsulation permet de déclencher de manière fiable l'événement OnDataModified.

La séparation entre le modèle de données (CursorConfigRecord) et sa représentation graphique potentielle (CursorUIBinding) préserve l'indépendance du module vis-à-vis des frameworks d'interface utilisateur, facilitant ainsi les tests unitaires et la réutilisation du code. Le script d'éditeur (CursorConfigInspector) démontre comment enrichir cette structure de base avec des outils de validation et de visualisation sans altérer la logique métier.

Étiquettes: Unity ScriptableObject CSharp Data-Management JSON-Serialization

Publié le 8 octobre à 23h56