Come sviluppare video games con Unity: approfondimenti

Nell’articolo precedente abbiamo visto in generale il funzionamento dell’interfaccia di Unity, abbiamo capito cos’è un GameObject, cos’è un Componente e come queste cose interagiscono tra loro.

In questa seconda e ultima parte entreremo un po’ più nel tecnico: capiremo come creare un semplice Componente personalizzato usando C#. Inoltre vedremo come far interagire tra loro i componenti e come creare nuovi GameObject durante il gioco usando i Prefab.

 

Componenti Personalizzati

Come dicevamo, è possibile creare componenti personalizzati e programmabili in C#.

Andiamo nella sezione Project, premiamo col destro e selezioniamo Create -> C# Script.

Un componente tipo è una classe che eredita da MonoBehaviour. Quando viene creata una nuova classe di questo tipo, l’editor la aggiungerà automaticamente alla lista dei componenti disponibili da aggiungere ai nostri GameObject.

È importante sapere che, per permettere all’editor di aggiungere alla lista dei componenti il nostro componente appena creato, è necessario che il nome del file sia esattamente uguale al nome della classe che eredita da MonoBehaviour. In caso contrario, Unity non riuscirà a trovarlo.

I componenti, come dicevamo, possono essere attivati o disattivati, sia dall’editor che da script usando l’attributo enabled del componente. Quando un componente viene disattivato, esso è ancora istanziato, è ancora possibile chiamare i suoi metodi, ma smette di ricevere i messaggi di lifecycle e di fatto smette di interagire con il gioco. Ma cosa sono i messaggi di Lifecycle?

 

Messaggi e Lifecycle dei componenti

L’interazione con il vero e proprio motore di gioco avviene tramite la ricezione di alcuni eventi speciali che caratterizzano le varie fasi di vita del componente.

Questi eventi sono chiamati nel gergo di Unity “Messages“, e funzionano tramite un meccanismo di reflection.

In pratica, è sufficiente implementare nella classe un metodo con un certo nome, e quel metodo verrà chiamato dall’engine di Unity all’occorrenza.

Facciamo un esempio: Un messaggio molto utile è `Start`. Esso viene chiamato da Unity quando il componente è stato inizializzato, il GameObject è pronto e sta per essere disegnato su schermo. In genere viene utilizzato al posto del costruttore per inizializzare lo stato del componente.

public class MyComponent : MonoBehaviour {
  void Start() {
    Debug.Log("Hello, I'm a component!");
  }
}

Creando questa nuova classe nel progetto, aggiungendolo al nostro GameObject e facendo “play” al gioco, vedrete apparire in console un semplice messaggio.

Semplice no? Di messaggi come questo ce ne sono molti e sono fondamentali per creare i propri componenti.

Ne listo alcuni notevoli, principalmente legati al ciclo di vita del componente e rimando alla lista completa sulla documentazione ufficiale:

  • Awake() viene chiamato appena il componente viene inizializzato, prima dello Start. Ad esempio, se abbiamo 10 componenti e vogliamo fare una certa operazione prima dello Start di tutti gli altri componenti, ci basterà implementare un Awake()
  • Update() Viene chiamato poco prima di ogni ciclo di refresh grafico, quindi in media ogni 1/60 esimo di secondo. Tramite il modulo Time, può essere utilizzato per fare azioni temporizzate, come animazioni, timer e altro.
  • OnEnable(), OnDisable() Ogni componente può essere attivato o disattivato in ogni momento: Quando un componente non è enabled, non riceve gli eventi Update(). Questi due eventi vengono chiamati ogni volta che il componente cambia di stato.
  • OnDestroy(): Questo viene invocato quando il componente o il GameObject sta per essere distrutto.

Importante: L’evento OnEnable viene chiamato anche all’inizializzazione del componente, subito dopo l’Awake. L’OnDisable() viene chiamato quando anche quando il GameObject sta per essere distrutto. Inoltre, quando si passa da una scena all’altra, tutti gli OnDestroy di ciascun componente di ciascun GameObject vengono richiamati!

Ecco un esempio completo:

public class MyComponent : MonoBehaviour {
  void Awake() {
    Debug.Log("Hello, I got initialized!");
  }
 
  void Start() {
    Debug.Log("Hello, I'm ready!");
  }
 
  void OnEnable() {
    Debug.Log("Hello, I'm enabled!");
  }
 
  void OnDisable() {
    Debug.Log("Hello, I'm disabled! :(");
  }
 
  void Update() {
    Debug.Log("I'm Updatiiiiing~");
  }
 
  void OnDestroy() {
    Debug.Log("Hello, I'm dead! X_X");
  }
}

È inoltre possibile mandare messaggi personalizzati, ed è un meccanismo utile per comunicare tra componenti. Per farlo, basta utilizzare il metodo SendMessage() di un GameObject.

È importante però tenere conto che questo meccanismo di scambio messaggi ha due principali problemi:

  • Al contrario dei messaggi di default che sono stati ottimizzati dal team di Unity con procedure ad-hoc, i messaggi personalizzati sono tendenzialmente poco efficienti, in quanto utilizzano la reflection. È meglio evitare di inviare messaggi troppo frequentemente (es: durante l’Update)
  • È più difficilmente testabile. Essendo che non esiste una referenza forte tra il metodo chiamato e il metodo chiamante, spesso il debugger non è in grado di ricostruire lo stack trace degli errori se di mezzo c’è una chiamata ad un SendMessage.

Nelle ultime versioni di Unity, infatti, sono stati introdotti altri meccanismi per scambiare messaggi tra componenti, come ad esempio gli UnityEvent.

 

Interazione tra componenti

Ciascun componente può interagire con il GameObject a cui è associato, al suo transform e a tutti gli altri componenti associati quel GameObject, tramite dei semplici metodi ereditati da MonoBehaviour. Inoltre possono interagire con l’intera gerarchia di GameObject associati al GameObject corrente.

Questi metodi sono:

  • this.transform, this.gameObject per ottenere il GameObject e il Transform correnti
  • GetComponent<T>() per avere il riferimento un certo componente T associato al GameObject corrente
  • this.transform.parent, this.transform.GetChild(index) per avere rispettivamente padre e figli del GameObject corrente.

Ecco un esempio molto semplice:

public class MyComponent : MonoBehaviour {
  void Start() {
    transform.position = new Vector3(3, 3, 0);
  }
}

Questo script, all’avvio del gioco, posiziona il GameObject corrente in posizione (3, 3, 0). Per conoscere tutti i vari metodi dei vari componenti, rimando alla documentazione della classe MonoBehaviour e a questa guida sui componenti.

Inoltre, è possibile configurare un componente in modo che dipenda strettamente da un altro. Ad esempio, facciamo finta che il nostro componente si occupi di interagire con un MeshRenderer e disattivarlo in alcune situazioni. In questo caso, il nostro componente non ha senso di esistere se il MeshRenderer non è presente.

È possibile esprimere questo vincolo tramite l’attributo RequireComponent:

[RequireComponent(typeof(MeshRenderer))]
public class MeshActivator : MonoBehaviour {
  void Start() {
    GetComponent&lt;MeshRenderer&gt;().enabled = false; // Qui siamo sicuri che GetComponent non restituirà mai `null`
  }
}

In questo modo, non appena proveremo ad aggiungere un componente MeshActivator ad un GameObject, Unity si occuperà di validare se è già presente un componente MeshRenderer in quel GameObject.

 

Serializzazione

I componenti hanno un’altra proprietà molto importante:

Ogni attributo pubblico di una classe MonoBehaviour è serializzabile. Che significa?
Significa che i valori degli attributi pubblici vengono salvati all’interno del GameObject e persistono nel tempo.

Facciamo un esempio. Prendiamo il nostro componente di prima e creiamo un nuovo attributo pubblico chiamato Name:

public class MyComponent : MonoBehaviour {
  public string Name;
}

Torniamo in Unity e noteremo una cosa curiosa: selezionando il GameObject a cui avremo applicato il componente, sarà comparsa una piccola box di testo in corrispondenza del componente, chiamata proprio Name, in cui è possibile scrivere del testo. Riempiendola con un testo a piacere, questo valore sarà serializzato (salvato) nel GameObject, e quando avvieremo il gioco, quel componente avrà nella variabile Name proprio quel valore. Magia!

Questo permette di definire dei parametri arbitrari per componente, il che li rende molto più personalizzabili e riutilizzabili.

Unity supporta la serializzazione per tantissimi tipi base (interi, stringhe, array, float, ecc…), per alcuni tipi di Unity (Es: GameObject, Component, ecc…) e per alcuni tipi complessi (List).

È possibile anche definire dei nuovi tipi serializzabili, per salvare strutture dati complesse all’interno dei nostri GameObject.
È sufficiente creare una classe e utilizzare l’attributo System.Serializable:

[System.Serializable]
public class MyPerson {
  public string Name;
  public float Age;
}
 
public class MyComponent : MonoBehaviour {
  public MyPerson Person;
}

Ecco cosa avverrà nell’editor:

Esistono inoltre degli attributi speciali che permettono di personalizzare come una certa proprietà viene mostrata nell’editor. Ad esempio è possibile renderizzare un campo di testo come TextArea, o imporre un Range di valori ai dati numerici.

Ecco un esempio:

[System.Serializable]
public class MyPerson
{
  public string Name;
  [Header("Additional Information")]
  [Range(1, 99)]
  public float Age;
  [TextArea]
  public string Description;
}
 
public class MyComponent : MonoBehaviour
{
  public MyPerson Person;
}

 

Il nostro primo componente

Proviamo a fare un po’ di pratica:

Appena avviato Unity, ci troveremo nella nostra prima scena vuota (o quasi, ricordate la Main Camera?). Premiamo col destro nella Hierarchy e selezioniamo 3D Object -> Sphere.

Questo creerà un GameObject con un MeshFilter, un MeshRenderer, con già associata la mesh di una sfera.
Ora, proviamo a fare un semplice componente che, al premere di un certo tasto, faccia muovere la nostra sfera nello spazio.

Come abbiamo imparato prima, transform.position può essere utile per spostare un oggetto nello spazio.

public class SphereController : MonoBehaviour {
  public float Speed = 1.0f;
  void Update() {
    transform.position += Vector3.right * Input.GetAxis("Horizontal") * Speed * Time.deltaTime;
  }
}

Notare che:

  • Impostando Speed = 1.0f, stiamo di fatto definendo il valore di default che apparirà nell’editor quando aggiungeremo il componente.
  • La classe Input è quello che fa per noi. Input.GetAxis(“Horizontal”) restituisce un numero compreso tra (-1, 1), con -1 significa “sto premendo la freccia sinistra”, e 1 significa “sto premendo la freccia destra”.
  • Vector3.right è una shortcut per new Vector3(1, 0, 0), ovvero un vettore da tre componenti rivolto a destra.
  • Time.deltaTime restituisce il numero di secondi (con la virgola) trascorso dall’ultimo update prima di questo.

Ma quindi, cosa stiamo andando a fare?

Semplice: ad ogni refresh dello schermo, stiamo sommando alla posizione corrente della sfera un vettore così calcolato:

  • Vector3.right * Input.GetAxis(“Horizontal”) decide la direzione di movimento. Quando non stiamo premendo nessun tasto, Input.GetAxis(“Horizontal”) sarà zero e il vettore sarà 0. Premendo destra, varrà (1, 0, 0), premendo sinistra varrà (1, 0, 0) * -1, ovvero (-1, 0, 0), ovvero il vettore che punta a sinistra.
  • Time.deltaTime * Speed servono a impostare la velocità di movimento. Essendo che Time.deltaTime è di circa 1/60 esimo di secondo, e Update() viene chiamato ogni circa 1/60 esimo di secondo, stiamo di fatto impostando la velocità a 1.0m/s

Ecco il risultato:

 

I Prefab

Come ultimo concetto per concludere questa introduzione, vorrei parlare dei Prefab.

Come abbiamo visto, ogni GameObject può avere ad esso associato tantissimi componenti diversi, ognuno coi suoi parametri. Inoltre un componente può avere figli che a loro volta possono avere altri figli.

Ipotizziamo ora di avere un gioco in cui abbiamo un fucile. Questo fucile spara proiettili, e ogni proiettile è un modello 3d, con un rigid body, una sua massa, energia, e uno script che controlla il danno inflitto ai nemici. Inoltre, come figlio, ha un altro GameObject che si occupa di mostrare un’animazione quando urta un altro oggetto. Insomma, è un oggetto piuttosto articolato.

Abbiamo detto che ogni scena può avere un qualsiasi numero di GameObject, quindi potremmo pensare di creare un oggetto proiettile e farlo apparire quando il giocatore spara. E se invece fosse un mitragliatore che spara dozzine di colpi al secondo? Avremmo necessità di creare nuovi proiettili continuamente!

Per fare questo esiste una funzionalità molto utile, che sono i Prefab.

Unity permette di prendere un GameObject creato in una scena ed esportarlo come file (prefab, appunto), e richiamarlo a piacere come prototipo per un nuovo GameObject. Per farlo, basta creare il nostro GameObject e trascinarlo in basso nella tab Project.

Potremo quindi, nel nostro caso, creare un GameObject “Bullet” e salvarlo come prefab.

Tramite il comando Instantiate(), è possibile create una copia di un prefab generando un nuovo GameObject nella scena.

Ecco un esempio di come fare un componente “Rifle“, che crea un nuovo proiettile ogni volta che si preme il tasto spazio:

public class Rifle : MonoBehaviour {
  public GameObject BulletPrefab; // Il nostro prefab di proiettile
 
  void Update() {
    if (Input.GetKey(KeyCode.Space)) {
      var projectile = Instantiate(BulletPrefab);
      projectile.transform.position = transform.position;
      projectile.GetComponent().velocity = Vector3.right;
    }
  }
}

In questo semplice script, nell’update controlliamo se il giocatore sta premendo il tasto spazio. Se sì, creiamo una nuova copia di BulletPrefab esattamente dove ci troviamo adesso. Infine, usando GetComponent<T>(), impostiamo una certa velocità al suo componente Rigidbody.

Notare che la proprietà BulletPrefab è una proprietà che apparirà anche nell’editor, inizialmente vuota. Per popolarla basterà trascinare all’interno il prefab del nostro proiettile dalla tab Project o usare l’apposito selettore.

Ed ecco il risultato finale:

 

Cosa abbiamo imparato?

Oggi abbiamo approfondito lo sviluppo in Unity: abbiamo capito come creare Componenti personalizzati e come farli interagire tra loro. Inoltre abbiamo visto come possiamo riutilizzare un GameObject o gruppi di GameObject tramite l’utilizzo di Prefab.


Hai un progetto da sviluppare?