Unity 3D Italia

Struttura e organizzazione iniziale

In questo articolo cercherò di darvi dei consigli paratici su come organizzare il lavoro e la struttura degli scripts basilari.
Consigli che sono alla base dello sviluppo di un videogioco su Unity, così da non ritrovarsi con decine di problematiche in seguito.

L’esempio della rete di nodi

Creare un gioco, programma o applicazione è un po’ come avere una corda con cui si vuole creare una complicata rete, composta da incroci, nodi e percorsi obbligati che ci portino al completamento del lavoro in modo corretto.
Se iniziate a lavorare sulla corda sbagliando il primo passo, non potrete correggerlo senza distruggere il lavoro fatto successivamente. Andrete avanti fino ad un certo punto fino a ritrovarvi con un groviglio di corda inestricabile. Lavorerete per giorni, mesi o anni con il risultato di ottenere un pastrocchio, sul quale andrete avanti cercando di procedere adattando il lavoro sulla base degli errori compiuti in precedenza.
A quel punto non sarà possibile neanche al più bravo dei programmatori districare il groviglio
e l’unica soluzione per portare a termine il lavoro sarà quello di sciogliere gran parte del lavoro e ricominciare da capo.


Pianificazione prima di tutto

Sappiamo che al caricamento di una nuova scena tutti i gameObjects (e script annessi) verranno distrutti con la conseguente perdita dei dati.
E se volessimo mantenere delle variabili sempre aggiornate anche al cambio di scena…
O mantenere oggetti attivi per tutta la durata di gioco, anche con continui cambi di scena…

Alcuni banali esempi:
Mettete che volessimo contare il numero di nemici uccisi e mantenere questo contatore anche al caricamento di una nuova scena.

Oppure come potremmo mantenere il valore di energia del player al caricamento di una nuova scena?

Oppure, un problema comune è quello che deriva dal fatto che nell’inspector di un prefab non è possibile impostare un oggetto della scena (ovvero, non è possibile trascinare un oggetto della scena nell’inspector di un prefab).
Se per esempio volete istanziare i nemici in ogni scena o ogni tot di tempo… Ad ogni nemico istanziato dovrete “dirgli” chi è il nemico, per farglielo seguire, attaccare ecc… Ma se l’istanza proviene da un prefab, esso non potrà avere un link ad un oggetto di una scena (questo perché un prefab è fondamentalmente un file, non un oggetto legato ad una scena particolare).
Dovrete quindi andare a fare un “Find(Player)” per ogni nemico appena istanziato, cosa che a livello di performance potrebbe essere altamente deleterio, soprattutto in presenza di un certo numero di nemici. Non sarebbe più facile avere sempre presente il gameObject del Player, senza mai distruggerlo, per poter “dire” ad ogni script nemico: “segui il player” senza dovergli dire “prima trova il gameObject del player, poi seguilo”?
Oppure;
come detto più volte, ad ogni caricamento di scena ogni oggetto della scena precedente viene distrutto. E per oggetto si intende, sia gameObject che scripts annessi e conseguentemente anche i valori delle variabili. Player compreso.
Dunque ad ogni caricamento di scena dovremmo dire a tutti gli script del gioco “questo è il player”. Poi dovremmo assegnargli l’energia che aveva nella scena precedente, lo stato generale, l’inventario, la sua posizione… cavolo, un sacco di roba da andare a riassegnare. 😯
Perché farlo, quando sappiamo che dovrebbe semplicemente rimanere come era un attimo prima, nello stato in cui era precedentemente al caricamento di una nuova scena?
Non faremmo prima semplicemente a non distruggere il Player e i suoi scripts al caricamento della nuova scena così che rimanga inalterato con tutti i sui parametri? E mantenere così un gameObject Player che venga assegnato solo all’avvio del gioco e non vanga mai più variato, senza il bisogno di andarlo a ricercare, riassegnare, ricollocare… con le sue variabili sempre assegnate, fino a che il gioco non verrà chiuso?

Avremmo bisogno di un GameObject/Script che rimanga in funzione per tutta la durata del gioco, dal suo avvio alla sua chiusura, tipicamente chiamato GameManager.

Un oggetto di questo tipo viene chiamato Singleton perché appunto singolo e unico, che verrà inizializzato una singola volta e a cui tutti gli altri scripts potranno fare sempre riferimento, in qualunque situazione del gioco/programma ci si trovi.

Il GameManager

La prima cosa da fare è creare un GameObject vuoto dove andremo ad attaccare un nuovo script chiamato GameManager. Chiamate anche lo stesso GameObject con lo stesso nome, così da averlo sempre sotto mano. In realtà potete chiamarlo come volete, ma lo standard da seguire sarebbe questo.


Questo script sarà il cuore del gioco, il “posto” in cui troverete tutte le variabili basilari del gioco, come il gameObject del Player, il suo punteggio, il suo rigidBody, la situazione globale del gioco e tutto ciò che vorrete che sia sempre accessibile in qualunque scena e situazione si trovi in quel momento il giocatore.
Il GameObject su cui sarà attaccato lo script GameManager sarà sempre presente in gerarchia, senza bisogno che esso venga inserito in tutte le scene, basta metterlo nella prima scena che viene caricata.

Questo script conterrà molte variabili statiche, ovvero tutte variabili uniche che non potranno avere istanze o valori diversi. Per esempio il gameObject Player sarà sempre uno e sempre quello, perchè ma di norma non potranno mai esserci due o più gameObjectPlayer” presenti nella scena.
Esempio:

 public class GameManager : MonoBehaviour {


        public static MyPlayerMovements myPlayerMovements; //Lo script che fa muovere il Player
        public static Rigidbody playerRigidBody; //Il RigidBody del Player
        public static GameObject playerGameObject; //Il GameObject del Player
        public static Camera activeCamera; //La telecamera del gioco
        public static int playerPoints; //Il punteggio del giocatore
        ......
        
        //Tutte variabili utili ed uniche, che potranno essere utilizzate 
        //anche in altri script durante lo svolgimento del gioco
NOTA:
In questo caso, essendo un esempio, ho ipotizzato che il programmatore abbia già il suo sistema per far muovere il Player e abbia chiamato lo script MyPlayerMovements.
In questo articolo si vuol evidenziare un sistema per “organizzare” gli scripts più importanti in un gioco e metterli “in un posto” dove siano sempre accessibili, per l’appunto, il GameManager. Quello che ho fatto con la rigapublic static MyPlayerMovements myPlayerMovements; non è diverso dalle altre righe sottostanti dove ho dichiarato il RigidBody, la Camera ecc…
Con la differenza che le classi RigidBody , Camera , GameObject ecc… esistono di natura tra le classi di Unity, mentre il MyPlayerMovements è una ipotetica classe che fa muovere il player, che il programmatore dovrebbe aver creato da se.

Ora basterà cercare gli oggetti nell’Awake() dello script, così da fissarli nel GameManager e renderli disponibili ad ogni altro script del progetto.
Ricordiamoci che Awake() è il primissimo metodo che viene eseguito all’attivazione di uno script, prima del primo frame della scena.

 public class GameManager : MonoBehaviour {


        public static MyPlayerMovements myPlayerMovements; //Lo script che fa muovere il Player
        public static Rigidbody playerRigidBody; //Il RigidBody del Player
        public static GameObject playerGameObject; //Il GameObject del Player
        ......
        
        void Awake(){
            myPlayerMovements = FindObjectsOfType<MyPlayerMovements>(); //Trovo lo script del movimento
            playerGameObject = GameObject.Find("Player"); //Trovo il gameObject del Player
            playerRigidBody = playerGameObject.GetComponent<RigidBody>(); //Trovo il RigidBody del Player
            .......

In questo modo, se per esempio avrete uno script dei nemici che dovrà seguire un target (che tipicamente è il gameObject del Player), potrete richiamare tale gameObject  “prendendolo” dal GameManager dove è stato ” trovato e fissato” all’inizio del gioco, senza ricercarlo nuovamente, cosa che sappiamo utilizza molte risorse che potrebbero generare un “lag” nel framerate del gioco.

 GameManager.playerGameObject

Con questa riga avrete a disposizione il gameObject del Player.
A prescindere da ogni altra cosa, in qualunque scena vi troviate, senza dover andare a ricercare ogni volta il gameObject del Player  perché già salvato sulla variabile statica nel GameManager che sarà sempre presente e accessibile per tutto il gioco.

Se per esempio avete uno script per far inseguire un oggetto chiamato ipoteticamente “targetToFollow” potrete assegnargli il Player come tale.
Esempio:

    public class ClasseNemico : MonoBehaviour {
        
    void FollowPlayer()
    {
        
        targetToFollow=GameManager.playerGameObject;
        
        //Di seguito potrete inserire il vostro codice per far seguire il player
   ......
    }

A questo punto dovremmo fare in modo che il GamaManager sia sempre presente in ogni scena e sia sempre lo stesso, con le stesse variabili che non vengano distrutte al caricamento di una nuova scena.

Singleton e DontDestroyOnLoad, un’accoppiata vincente

Per fare questo si usa un Singleton, ovvero un sistema  che ha lo scopo di garantire che di una determinata classe venga creata una e una sola istanza, e di fornire un punto di accesso globale a tale istanza. Dunque ci sarà un solo oggetto di tipo GameManager per tutto il gioco e non ce ne potranno mai essere più di uno.

Un Singleton in combinazione con la funzione “DontDestroyOnLoad” di Unity permettono di mantenere un gameObject e tutti gli script ad esso attaccati, unici e presenti in tutte le scene, senza che essi vengano distrutti e ricreati al passaggio di scena, mantenendosi inalterati e senza che vengano mai più rieseguiti i metodi Awake o Start degli script, neanche al caricamento di una nuova scena.

 

Lo scopo dell’istruzione DontDestroyOnLoad è quello di rendere permanente e inalterato un gameObject e i suoi componenti anche al cambio di scena.

I metodi Awake e Start di questo script verranno eseguiti sempre e solo una sola volta, all’inizio del gioco, perché appunto, non ci sarà mai un “nuovo inizio” per questo script, ma esso sarà sempre presente e perseverante in tutto il gioco, a differenza di ogni altro script che viene distrutto e ricreato ad ogni inizio di scena.
Questo sarà molto utile per effettuare tutte quelle operazioni che vorremmo effettuare una sola volta e poi mai più, come la ricerca del Player, l’assegnazione di determinate variabili statiche e molte altre.

       public class GameManager : MonoBehaviour {

        public static GameManager instance; //Dichiaro un'istanza del GamaManager
        public static MyPlayerMovements myPlayerMovements;
        public static Rigidbody playerRigidBody;
        public static GameObject playerGameObject;
        public static Camera activeCamera;
        
        void Awake()
        {
            //Codice che definisce un Singleton
            
            //Nell'Awake, alla prima esecuzione del gioco, gli dico che:
            //se non esiste un'istanza di GameManager allora l'istanza è questo stesso script. 
            //mentre se l'istanza già esiste, cancella questo gameObject così da utilizzare il GameManager già presente      
            if (instance == null)
            {
            instance = this;
            DontDestroyOnLoad(transform.root.gameObject); //Con questa istruzione rendo "permanente" questo GameObject
            }
            else
            {
            Destroy(transform.root.gameObject);
            return; 
                    }
                    
            //Qui finisce il codice che definisce un Singleton
                    
           }

Riassumendo
Una cosa che dovrete sempre tenere presente è che ogni oggetto in una scena sarà distrutto nel momento in cui la scena stessa verrà distrutta (cioè ad ogni passaggio di scena) a meno che non si determini prima che un oggetto è da mantenere anche al passaggio di scena, con questa utile istruzione “DontDestroyOnLoad” .
l’istruzione “DontDestroyOnLoad” permette di mantenere un gameObject anche al passaggio tra le scene.

Noterete che al momento del “Play” gli oggetti che sono stati definiti “DontDestroyOnLoad“, in Hierarchy saranno posti sotto un apposita sezione, così da rendere subito intuibile che essi sono in qualche modo “staccati” e indipendenti dalla scena in corso e fanno parte di una speciale di scena globale permanente.

 
 

Exit mobile version