[CF - AL] Instanciation de ScriptableObjects à partir de prefabs

Pour les scripts écrits en C#
Règles du forum
Merci de respecter la NOMENCLATURE suivante pour vos TITRES de messages :

Commencez par le niveau de vos scripts
DB = Débutant
MY = Moyen
CF = Confirmé

Puis le domaine d'application
-RS = Réseau
-AL = Algorithmie

Exemple :

[DB-RS] Mouvement perso multijoueur
Avatar de l’utilisateur
Alesk
Messages : 2303
Inscription : 13 Mars 2012 09:09
Localisation : Bordeaux - France
Contact :

[CF - AL] Instanciation de ScriptableObjects à partir de prefabs

Message par Alesk » 10 Mars 2020 12:16

Salut tout le monde !

Je suis en train de faire des essais de structuration de projet à partir de ScriptableObjects (SO)
Inspiration ici https://www.youtube.com/watch?v=raQ3iHhE_Kk

L'intérêt est de centraliser l'accès à certaines valeurs, de rendre beaucoup plus modulaire le projet et aussi d'optimiser le rafraichissement de leur affichage via des Actions/Delegates, en n'effectuant la mise à jour de l'affichage QUE lorsque la valeur change.

A la différence de cette vidéo, où tous les SO sont créés et assignés à l'avance dans l'éditeur, je cherche un moyen de faire ça à la volée lors de l'exécution de mon jeu.

En prenant comme exemple mon proto de jeu Bomberman, où je veux afficher les données de plusieurs joueurs, il faudrait que je créé à la main des tas de SO redondants, ce qui est fastidieux dans une premier temps, et lourd à l'usage plus tard... Donc il me parait plus judicieux de créer un seul template qui sera ensuite simplement dupliqué lors de l'exécution du jeu à chaque fois qu'un joueur est ajouté à la partie.

Le problème et alors de parvenir à assigner au(x) bon(s) endroit(s) les SO nouvellement créés.

IMPORTANT : Le code actuel fonctionne mais est un brouillon qui ne me convient pas vraiment, car encore beaucoup trop figé à certains endroits.

Voici comment il est structuré :

Pour les valeurs atomiques, j'ai créé une classe de base, nommée SO_Variables qui est une extension de la classe ScriptableObject.
Cette classe permet d'accéder aux valeurs, de gérer les Actions/Delegates pour les mises à jour et de se cloner pour générer les différentes instances.

Code : Tout sélectionner

using UnityEngine;

public class SO_Variable<T> : ScriptableObject
{
    public delegate void OnVariableChangeDelegate(T newVal);
    public event OnVariableChangeDelegate OnChange;   

    private SO_Variable<T> originalReference;

    [SerializeField]
    private T _value;

    public T value{
        get{
            return _value;
        }
        set{
            if (_value.Equals(value)) return;
            _value = value;

            if (OnChange != null){
                OnChange(_value);
            }
        }
    }

    public void Refresh(){
        if (OnChange != null){
            OnChange(_value);
        }
    }

    public void OnValidate(){
        Refresh();
    }

    public void Reset(){
        if(originalReference != null){
            this.value = originalReference.value;
        }
    }

    private void SetOriginalReference(SO_Variable<T> original){
        originalReference = original;
    }

    protected SO_Variable<T> PrepareClone(){
        SO_Variable<T> clone = Instantiate(this) as SO_Variable<T>;
        clone.SetOriginalReference(this);
        return clone;
    }
}
J'ai ensuite 3 petites classes très simples qui me permettent de créer chaque type de valeur, en étendant SO_Variables et en spécifiant le type de variable (pour le moment float, int, string)

Code : Tout sélectionner

using UnityEngine;

[CreateAssetMenu(fileName = "IntVar", menuName = "SO_Game/Int Var", order = 2)]
public class IntVar : SO_Variable<int>
{
    public override string ToString(){
        return this.value.ToString();
    }

    public IntVar GetClone(){
        return this.PrepareClone() as IntVar;
    }
}

Code : Tout sélectionner

using UnityEngine;

[CreateAssetMenu(fileName = "FloatVar", menuName = "SO_Game/Float Var", order = 2)]
public class FloatVar : SO_Variable<float>
{
    public override string ToString(){
        return this.value.ToString("f4");
    }

    public FloatVar GetClone(){
        return this.PrepareClone() as FloatVar;
    }
}

Code : Tout sélectionner

using UnityEngine;

[CreateAssetMenu(fileName = "IntVar", menuName = "SO_Game/String Var", order = 2)]
public class StringVar : SO_Variable<string>
{
    public override string ToString(){
        return this.value.ToString();
    }

    public StringVar GetClone(){
        return this.PrepareClone() as StringVar;
    }
}
A partir de là, je peux créer mes conteneurs de données instanciables, et qui gèrent les évènements de mises à jour.

Ensuite vient le début des "ennuis", avec une classe un peu moins souple, nommée PlayerData, et qui me sert à regrouper les différentes valeurs associées à un joueur.
Ici j'ai tous les champs en double : seuls ceux suffixés avec "DEFAULT" doivent être renseignés depuis l'inspector, il servent de SO originaux qui contiendront les valeurs par défaut, et ils seront clonés à l'initialisation afin de générer les SO définitifs qui serviront dans le jeu.

Code : Tout sélectionner

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = "PlayerData", menuName = "SO_Game/Player Data", order = 1)]
public class PlayerData : ScriptableObject
{
    [HideInInspector]
    public int index = -1;

    public StringVar playerNameDEFAULT;
    public IntVar deathCountDEFAULT;
    public IntVar bombCountDEFAULT;
    public FloatVar lifeDEFAULT;

    [Space(20)]
    
    public StringVar playerName = null;
    public IntVar deathCount = null;   
    public IntVar bombCount = null;   
    public FloatVar life = null;

    void Awake()
    {
        playerName = playerNameDEFAULT.GetClone();
        deathCount = deathCountDEFAULT.GetClone();
        bombCount = bombCountDEFAULT.GetClone();
        life = lifeDEFAULT.GetClone();
    }

    public void Reset(){
        deathCount.Reset();
        bombCount.Reset();
        life.Reset();
    }

    public void Refresh(){
        deathCount.Refresh();
        bombCount.Refresh();
        life.Refresh();
    }


}
Premier hic : il serait beaucoup plus propre de ne pas avoir ces déclarations de variables en double.

La création des clones se passe dans la fonction Awake().
La fonction Reset() sert à assigner à tous les SO instanciés la valeur originale se trouvant dans le SO "DEFAULT"
La fonction Refresh() force l'appel du rafraichissement de toutes les valeurs.

Cette classe possède une valeur index, très importante, qui sert à l'identifier de manière unique.

Au dessus de ça vient le SO nommé DataManager qui sert à référencer correctement chaque instance de PlayerData, pour que les index soient cohérents.

Code : Tout sélectionner

using UnityEngine;
using System.Collections.Generic;

[CreateAssetMenu(fileName = "DataManager", menuName = "SO_Game/Data Manager", order = 0)]
public class DataManager : ScriptableObject
{
    public PlayerData playerDataDEFAULT;

    [HideInInspector]
    public List<PlayerData> playerDataList = new List<PlayerData>();

    void OnEnable(){
        playerDataList.Clear();
    }

    void OnDisable(){
        playerDataList.Clear();
    }

    public PlayerData GetPlayerData(int index = -1){
        if(index > -1 && index < playerDataList.Count){
            // Use existing element
            return playerDataList[index];
        }

        PlayerData clone = Instantiate(playerDataDEFAULT) as PlayerData;
        if(index > -1 && index < playerDataList.Count){
            // insert element
            clone.index = index;
            playerDataList.Insert(index,clone);
        }else{
            // add element
            clone.index = playerDataList.Count;
            playerDataList.Add(clone);
        }
        return clone;
    }
}
Viennent ensuite les scripts (PlayerScript, PlayerCanvasScript et LifeBarScript) assignés aux préfabs de mes objets qui seront visibles dans la scène.
Ils étendent tous les classe PlayerDataReader, qui sert à chopper la bonne instance de PlayerData, en fonction de l'index associé à l'instance du préfab en cours.

Cette classe de base (PlayerDataReader) recevera via l'inspecteur le SO DataManager.

Code : Tout sélectionner

using UnityEngine;

public class PlayerDataReader : MonoBehaviour
{   
    public DataManager dataSource;

    public PlayerData myData;

    public void InitData(int index){
        myData = dataSource.GetPlayerData(index);
    }
}
Lors du l'exécution du Awake() de chaque script la fonction InitData() est appelée avec en paramètre la valeur instancesCount propre à chaque classe, qui permet de définir l'index de l'instance en cours.
Du côté de DataManager, ça créera et retournera l'instance du SO PlayerData correspondante, ou bien la retournera directement si elle existe déjà.

Ainsi, peu importe l'ordre d'initialisation des scripts PlayerScript ou PlayerCanvasScript par exemple, tout étant calé sur l'index. Et il est bien entendu possible de ne créer que l'un ou l'autre sans que ça pose le moindre problème.

Second hic : je ne suis pas super satisfait de l'utilisation du Awake ici, j'aurais préféré pouvoir déporter ça dans PlayerDataReader, y compris l'incrémentation de instancesCount ... mais dans ce cas la valeur de cette variable n'est plus associée à chaque type de classe et est globale aux trois.

Dans PlayerScript, j'ai une instanciation automatique du canvas affichant les données de chaque player et une mise à jour random des valeurs.

Code : Tout sélectionner

using UnityEngine;

public class PlayerScript : PlayerDataReader
{
    static int instancesCount = 0;
    //----------------------------------------------------

    public PlayerCanvasScript canvasPrefab;

    void Awake() {
        InitData(instancesCount);
        instancesCount++;
    }

    void Start()
    {
        Instantiate(canvasPrefab);
        
        myData.playerName.value = name;

        myData.Refresh();

        InvokeRepeating("Rnd",0f,0.3f);
    } 

    void Rnd(){
        myData.deathCount.value = Random.Range(0,50);
        myData.bombCount.value = Random.Range(0,10);
        myData.life.value = Random.Range(0f,100f);
    }
    
}
Dans PlayerCanvasScript, j'ai un exemple d'association des Actions sur la fonction OnChange de chaque valeur afin de mettre les champs à jour.

Code : Tout sélectionner

using UnityEngine;
using TMPro;

public class PlayerCanvasScript : PlayerDataReader
{   
    static int instancesCount = 0;
    //----------------------------------------------------

    public RectTransform pivot;
    public TMPro.TextMeshProUGUI playerNameLabel;
    public TextMeshProUGUI bombsLabel;
    public TextMeshProUGUI deathLabel;

    void Awake() {
        InitData(instancesCount);
        instancesCount++;
    }

    void Start(){
        pivot.anchoredPosition = new Vector2(myData.index * pivot.rect.width,0);
    }

    void OnEnable(){
        if(myData){
            myData.playerName.OnChange += NameUpdate;
            myData.deathCount.OnChange += DeathUpdate;
            myData.bombCount.OnChange += BombsUpdate;
        }
    }

    void OnDisable(){
        if(myData){
            myData.playerName.OnChange -= NameUpdate;
            myData.deathCount.OnChange -= DeathUpdate;
            myData.bombCount.OnChange -= BombsUpdate;
        }
    }
    
    void NameUpdate(string value){
        playerNameLabel.text = value;
    }

    void DeathUpdate(int value){
        deathLabel.text = value+" Deaths";
    }

    void BombsUpdate(int value){
        bombsLabel.text = value+" Bombs";
    }

}
Dans LifeBarScript, j'ai un exemple de champ qui se cale directement sur une des valeurs de PlayerData, et une animation sur la barre.

Code : Tout sélectionner

using UnityEngine;
using UnityEngine.UI;
public class LifeBarScript : PlayerDataReader
{
    
    private static int instancesCount = 0;
    //----------------------------------------------------

    public RawImage lifeBar;
    RectTransform _rect;
    
    private float currentValue;
    private float newValue;

    Vector2 size;

    void Awake() {
        InitData(instancesCount);
        instancesCount++;
    }

    void Start(){
        _rect = lifeBar.rectTransform;
        size = _rect.sizeDelta;
        size.x = myData.life.value;
        ApplySize();
    }

    void OnEnable(){
        myData.life.OnChange += SetValue;
    }

    void OnDisable(){
        myData.life.OnChange -= SetValue;
    }

    void SetValue(float value)
    {
        newValue = value;
    }

    void Update(){
        if(size.x != newValue){
            size.x = Mathf.Lerp(size.x , newValue , Time.deltaTime * 2);
            ApplySize();
        }
    }

    void ApplySize(){
        _rect.sizeDelta = size;
    }
}

Voilà... Vous trouverez en pièce jointe un package avec une scène de démo où 3 instances du prefab de joueur sont déjà dans la scène. En dupliquant manuellement un de ces prefabs lors de l'exécution, vous verrez apparaitre le canvas correspondant et ses valeurs personnalisées se mettre à jour.

Donc je suis preneur de toute suggestion permettant d'assouplir et améliorer tout ça !
Pièces jointes
so_test.zip
(14.22 Kio) Téléchargé 129 fois

Avatar de l’utilisateur
Alesk
Messages : 2303
Inscription : 13 Mars 2012 09:09
Localisation : Bordeaux - France
Contact :

Re: [CF - AL] Instanciation de ScriptableObjects à partir de prefabs

Message par Alesk » 11 Mars 2020 15:03

Premier hic réglé : il suffit de directement assigner les SO à mes variables de travail et de créer les clones au démarrage.
ça implique juste de toujours créer les nouvelles instances à partir du prefab, et non à partir d'une instance existante.

Répondre

Revenir vers « (C#) CSharp »