Unity : Gérer le State

Lorsqu'on code un jeu avec Unity, invariablement se pose la question du State et comment le gérer.

Le State correspond à l'état dans lequel se trouve le jeu à un instant t.

Par exemple, votre jeu va très certainement démarrer sur un Splash Screen, puis afficher une page d'introduction, permettant de choisir des options et de démarrer le jeu.

On va peut être passer par une page d'exposition, expliquant la scène dans laquelle le joueur va arriver, puis on va jouer.

Il se peut qu'on perde une vie, puis qu'on meurt ou qu'on réussisse le niveau.

Tout cela peut se modéliser en une suite d'états :

public enum GameState {
SplashScreen,
Intro,
Settings,
Playing,
DeathAnimation,
GameOver,
Winner,
...
}

On peut passer d'un état à un autre : seulement certaines transitions sont possibles.

De cet état va dépendre la visibilité des objets ou des éléments d'UI du jeu. La gestion du State est donc vitale pour le développement.

***

Il y a plusieurs façons de gérer ce State pour un développeur.

Unity ne définit aucune règle à ce sujet - l'implémentation est libre et dépend de chacun.

On peut utiliser le State Pattern - dont on peut trouver de nombreuses implémentations sur Internet. 

Ces implémentations sont très complètes - elles permettent notamment de gérer facilement des transitions d'animations de modèles 3D - , mais parfois un peu complexes pour des applis simples, et un peu dures à faire évoluer. 

En ce qui me concerne, j'ai des besoins plus simples ; je n'ai pas la prétention de proposer la meilleure solution, mais au bout de plusieurs essais de gestion du State, je pense être arrivé à un bon compromis entre :

  • le temps de développement / la complexité d'implémentation
  • la possibilité d'ajouter facilement de nouveaux états, de nouveaux éléments
  • la lisibilité du code

On oublie parfois de prendre en compte l'évolutivité de la solution (la capacité d'ajouter un nouvel état, parce qu'on ajoute une étape qui n'était pas prévue), c'est une erreur qui peut coûter cher en temps de développement.

***

D'un point de vue de la structure du code, on va créer un objet GameManager (de façon assez classique) qui va être le seul habilité à faire passer le State d'une valeur à une autre : c'est important pour bien séparer et isoler les responsabilités (et s'éviter des heures de debug...😒).

GameManager va invoquer un event à chaque modification de valeur de State ; tous les objets qui souscrivent à l'event recevront donc la modification de l'état, et pourront s'afficher ou se masquer.

A ce propos :

Il est tentant avec Unity de Enable ou Disable entièrement un objet pour le masquer. C'est simple et rapide.

MAIS : dans la technique que je propose, on ne peut pas Disable un panel d'UI entièrement, par exemple, sinon il sera incapable de recevoir un event !!!

Ce que je propose en revanche, c'est de Disable tous les fils du panel (et pas le panel lui même) ; ainsi, il continuera à recevoir l'event de changement de State, et pourra ré afficher ses fils.

On crée un script CommonTools.cs qui contiendra les méthodes utilisées pour afficher ou masquer les fils d'un panel :

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

/// <summary>
/// Tools used to display / hide all the children of a panel
/// </summary>
public class CommonTools : MonoBehaviour
{
    public static void HideChildren(GameObject go)
    {
        // Hide children
        for (int i = 0; i < go.transform.childCount; i++)
        {
            var child = go.transform.GetChild(i);
            child.gameObject.SetActive(false);
        }
    }

    public static void ShowChildren(GameObject go)
    {
        // Show
        for (int i = 0; i < go.transform.childCount; i++)
        {
            var child = go.transform.GetChild(i);
            child.gameObject.SetActive(true);
        }

    }
}

Au changement d'état, les panels qui doivent s'afficher / se masquer appelleront ces méthodes - on verra ça après.

On crée un script GameManager.cs qu'on associe à l'objet GameManager.

Dans ce script, on définit l'enum comprenant les différents State possibles, et on va ajouter une Action qui est un event qui sera invoqué à chaque changement de State :

public class GameManager : MonoBehaviour
{
    public static Action<GameState> StateHasChanged;    // Event invoked when state changes

 ...
}

public enum GameState
{
    Intro,
    Playing,
    GameOver,
    Winning
}


Pour l'exemple, on va ajouter un PanelIntro qui s'affiche au démarrage du jeu - qui affiche les options et un bouton Play.

La hiérarchie des objets sera la suivante :

L'objet PanelIntro est un Panel, dont on a retiré l'image (le panel est un objet graphiquement vide), et qui utilise un script PanelIntro.cs.

Le code de PanelIntro.cs :

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

public class PanelIntro : MonoBehaviour
{
    public static Action PanelIntroHasClosed;

    private void Awake()
    {
        // Register to GameManager change state
        GameManager.StateHasChanged += GameManager_StateHasChanged;
    }

    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        
    }

    private void GameManager_StateHasChanged(GameState newState)
    {
        if (newState == GameState.Intro)
            CommonTools.ShowChildren(this.gameObject); // Show all the children
        else
            CommonTools.HideChildren(this.gameObject);
    }

    public void BtnClosedHasBeenPressed()
    {
        PanelIntroHasClosed?.Invoke();
    }
}

Quelques explications :

  • Le bouton dans le Panel appelle la fonction BtnClosedHasBeenPressed() de l'objet PanelIntro
  • PanelIntro a un event PanelIntroHasClosed qui sera déclenché quand on appuie sur le bouton
  • PanelIntro s'abonne à l'event StateHasChanged de GameManager
  • PanelIntro affiche tous ses fils quand le State = Intro, sinon les masque 

Et voici enfin le code du script GameManager.cs :

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.SocialPlatforms;

public class GameManager : MonoBehaviour
{
    public static Action<GameState> StateHasChanged;    // Event invoked when state changes

    private GameState state;    // Current State of the game

    private void Awake()
    {
        // Register to PanelIntro events
        PanelIntro.PanelIntroHasClosed += PanelIntro_PanelIntroHasClosed;
    }

    // Start is called before the first frame update
    void Start()
    {
        // Starts by setting the default State (this way, objects will show / hide accordingly)
        switchToNextState(GameState.Intro);
    }

    // Update is called once per frame
    void Update()
    {
        
    }

    private void switchToNextState(GameState newState)
    {
        Debug.Log("Switch to" + newState.ToString());
        state = newState;

        // Send message to observers
        StateHasChanged?.Invoke(newState);

        switch (newState)
        {
             case GameState.Intro:
                // Control is passed to PanelIntro object
                break;
            case GameState.Playing:
                // Do what you need to do
                // ...
                break;
            case GameState.GameOver:
                // Control is passed to PanelIntro object
                break;
        }

    }

    private void PanelIntro_PanelIntroHasClosed()
    {
        // Ok, panel intro has been closed
        // Time to do some stuff and switch to another state - for instance Playing
        // ...

        switchToNextState(GameState.Playing);
    }

}

public enum GameState
{
    Intro,
    Playing,
    GameOver,
    Winning
}

Quelques explications à nouveau :

  • GameManager est le seul à changer le State via la méthode privée switchToNextState : ainsi aucun risque de modifier le state dans un autre objet
  • GameManager s'abonne à l'event de PanelIntro au début
  • Pour mettre à jour le State, on appelle switchToNextState dès la méthode Start()

Voici donc le fonctionnement global :

  • Chaque objet fils (Panel notamment) s'abonne à StateHasChanged pour savoir quand le state change, puis affiche / masque ses fils, et fait ce qu'il a à faire
  • GameManager laisse chaque Panel gérer son fonctionnement propre : responsabilités respectées
  • Si on veut ajouter un nouveau State et un nouveau Panel : il faudra ajouter un event dans le nouveau Panel, et que GameManager s'y abonne 

L'avantage de ce système, est qu'il est facile de faire évoluer un projet et d'ajouter plus sans être noyé dans le code et les effets de bords.

N'hésitez pas à tester et à me donner votre avis 😉.